From a899a959587e2d9f6f027089d814bed00b71bf49 Mon Sep 17 00:00:00 2001 From: mmuttreja-tableau <87720143+mmuttreja-tableau@users.noreply.github.com> Date: Thu, 21 Oct 2021 12:10:37 -0400 Subject: [PATCH 001/296] Adjusting changelog to include missing updates for release 0.17 (#922) --- CHANGELOG.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e375f8385..f5c753cdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,17 @@ ## 0.17.0 (20 October 2021) -Update publish.sh to use python3 (#866) -Fixed jobs.get_by_id(job_id) example & reference docs (#867, #868) -Fixed handling for workbooks in personal spaces which do not have projectID or Name (#875) -Updated links to Data Source Methods page in REST API docs (#879) -Upgraded to newer Slack action provider (#880) -Added support to the package for getting flow run status, as well as the ability to cancel flow runs. (#884) +* Added support for accepting parameters for post request of the metadata api (#850) +* Fixed jobs.get_by_id(job_id) example & reference docs (#867, #868) +* Fixed handling for workbooks in personal spaces which do not have projectID or Name (#875) +* Updated links to Data Source Methods page in REST API docs (#879) +* Unified arguments of sample scripts (#889) +* Updated docs for - links to Datasource API (#879) , sample scripts (#892) & metadata query (#896) +* Added support for scheduling DataUpdate Jobs (#891) +* Exposed the fileuploads API endpoint (#894) +* Added a new sample & documentation for metadata API (#895, #896) +* Added support to the package for getting flow run status, as well as the ability to cancel flow runs. (#884) +* Added jobs.wait_for_job method (#903) +* Added description support for datasources item (#912) +* Dropped support for Python 3.5 (#911) ## 0.16.0 (15 July 2021) * Documentation updates (#800, #818, #839, #842) From c37c6c2452a77da159900f25c659b121c0a65650 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Mon, 1 Nov 2021 20:36:17 -0400 Subject: [PATCH 002/296] Fix slack once and for all (#946) The red X keeps coming back so I'd like to mark this as allowably fail-able -- that way the innards of Slack/OAuth/Tableau credentials don't keep polluting test run reports :) (cherry picked from commit c8170ae195e39981d2649bacb2e4682e7a92a73d) --- .github/workflows/slack.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/slack.yml b/.github/workflows/slack.yml index c3b17e8c4..05671333f 100644 --- a/.github/workflows/slack.yml +++ b/.github/workflows/slack.yml @@ -4,6 +4,7 @@ on: [push, pull_request, issues] jobs: slack-notifications: + continue-on-error: true runs-on: ubuntu-20.04 name: Sends a message to Slack when a push, a pull request or an issue is made steps: From e4d25c1020eb628b5839b35d219116e50b00e5a3 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Tue, 26 Oct 2021 08:04:10 -0700 Subject: [PATCH 003/296] Switch to release Python 3.10 release for CI (#927) * Switch to release Python 3.10 release for CI (cherry picked from commit feed39c2e0d9398d4165c0521c92f4003e874658) --- .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 61476132f..819cbb902 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.6, 3.7, 3.8, 3.9, 3.10.0-rc.2] + python-version: ['3.6', '3.7', '3.8', '3.9', '3.10'] runs-on: ${{ matrix.os }} From 34ed9b57f0ffd028be35ddbbb81084066fa21c8e Mon Sep 17 00:00:00 2001 From: yoshichan5 Date: Fri, 28 Jan 2022 09:07:02 +0900 Subject: [PATCH 004/296] change distutils to packaging (#977) --- tableauserverclient/server/endpoint/endpoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 31291abc9..3372afdf1 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -12,7 +12,7 @@ try: from distutils2.version import NormalizedVersion as Version except ImportError: - from distutils.version import LooseVersion as Version + from packaging.version import Version logger = logging.getLogger("tableau.endpoint") From ea5945c4da4522e724cb49d60d4537d307416416 Mon Sep 17 00:00:00 2001 From: TableauKyle <92327461+TableauKyle@users.noreply.github.com> Date: Sat, 12 Mar 2022 19:36:07 -0800 Subject: [PATCH 005/296] Include 'parquet' as a publishing file type (#984) --- tableauserverclient/server/endpoint/datasources_endpoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index c031004e0..18a2f318c 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -23,7 +23,7 @@ # The maximum size of a file that can be published in a single request is 64MB FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB -ALLOWED_FILE_EXTENSIONS = ["tds", "tdsx", "tde", "hyper"] +ALLOWED_FILE_EXTENSIONS = ["tds", "tdsx", "tde", "hyper", "parquet"] logger = logging.getLogger("tableau.endpoint.datasources") From 4a1656e8ddd61b517ceecbbcebf03800f4e2782c Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 16 Mar 2022 17:03:12 -0700 Subject: [PATCH 006/296] Create feature_request.md (#1005) * Create feature_request.md Add a template to differentiate bug reports and feature requests from users. --- .github/ISSUE_TEMPLATE/feature_request.md | 28 +++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..b7a7a926d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,28 @@ +--- +name: Feature Request +title: "[REQUEST TYPE] [FEATURE TITLE]" +about: Suggest a feature that could be added to the client +labels: enhancement, needs investigation +--- + +## Summary +A one line description of the request. Skip this if the title is already a good summary. + + +## Request Type +If you know, say which of these types your request is in the title, and follow the suggestions for that type when writing your description. + +****Type 1: support a REST API:**** +If it is functionality that already exists in the [REST API](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm), example API calls are the clearest way to explain your request. + +****Type 2: add a REST API and support it in tsc.**** +If it is functionality that can be achieved somehow on Tableau Server but not through the REST API, describe the current way to do it. (e.g: functionality that is available in the Web UI, or by using the Hyper API). For UI, screenshots can be helpful. + +****Type 3: new functionality**** +Requests for totally new functionality will generally be passed to the relevant dev team, but we probably can't give any useful estimate of how or when it might be implemented. If it is a feature that is 'about' the API or programmable access, here might be the best place to suggest it, but generally feature requests will be more visible in the [Tableau Community Ideas](https://community.tableau.com/s/ideas) forum and should go there instead. + + +## Description +A clear and concise description of what the feature request is. If you think that the value of this feature might not be obvious, include information like how often it is needed, amount of work saved, etc. If your feature request is related to a file or server in a specific state, describe the starting state when the feature can be used, and the end state after using it. If it involves modifying files, an example file may be helpful. +![](https://img.shields.io/badge/warning-Be%20careful%20not%20to%20post%20user%20names%2C%20passwords%2C%20auth%20tokens%20or%20any%20other%20private%20or%20sensitive%20information-red) + From 9ec60ac573792f8809b7f74a2bae4b04a2f06a1a Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 6 Apr 2022 12:59:59 -0700 Subject: [PATCH 007/296] Release with security and functionality updates (#1021) Switched to using defused_xml for xml attack protection added linting and type hints improve experience with self-signed certificates/invalid ssl updated samples new item types: metrics, revisions for datasources and workbooks features: support adding flows to schedules, exporting workbooks to powerpoint fixes: delete extracts --- .github/workflows/meta-checks.yml | 35 + .github/workflows/publish-pypi.yml | 37 + .github/workflows/run-tests.yml | 9 +- .github/workflows/slack.yml | 1 + CHANGELOG.md | 10 + MANIFEST.in | 7 +- README.md | 4 +- contributing.md | 13 +- samples/add_default_permission.py | 32 +- samples/create_group.py | 29 +- samples/create_project.py | 49 +- samples/create_schedules.py | 129 +- samples/download_view_image.py | 44 +- samples/explore_datasource.py | 44 +- samples/explore_webhooks.py | 35 +- samples/explore_workbook.py | 50 +- samples/export.py | 63 +- samples/export_wb.py | 41 +- samples/extracts.py | 75 ++ samples/filter_sort_groups.py | 76 +- samples/filter_sort_projects.py | 78 +- samples/initialize_server.py | 43 +- samples/kill_all_jobs.py | 27 +- samples/list.py | 50 +- samples/login.py | 82 +- samples/metadata_query.py | 44 +- samples/move_workbook_projects.py | 67 +- samples/move_workbook_sites.py | 55 +- samples/pagination_sample.py | 109 +- samples/publish_datasource.py | 62 +- samples/publish_workbook.py | 56 +- samples/query_permissions.py | 46 +- samples/refresh.py | 33 +- samples/refresh_tasks.py | 37 +- samples/set_http_options.py | 52 - samples/set_refresh_schedule.py | 47 +- samples/update_connection.py | 44 +- samples/update_datasource_data.py | 33 +- setup.cfg | 9 +- setup.py | 7 +- tableauserverclient/__init__.py | 8 +- tableauserverclient/_version.py | 10 +- tableauserverclient/exponential_backoff.py | 12 +- tableauserverclient/models/__init__.py | 20 +- tableauserverclient/models/column_item.py | 4 +- tableauserverclient/models/connection_item.py | 5 +- .../models/data_acceleration_report_item.py | 4 +- tableauserverclient/models/data_alert_item.py | 85 +- tableauserverclient/models/database_item.py | 16 +- tableauserverclient/models/datasource_item.py | 118 +- tableauserverclient/models/dqw_item.py | 13 +- tableauserverclient/models/favorites_item.py | 47 +- tableauserverclient/models/fileupload_item.py | 4 +- tableauserverclient/models/flow_item.py | 59 +- tableauserverclient/models/flow_run_item.py | 70 +- tableauserverclient/models/group_item.py | 58 +- tableauserverclient/models/interval_item.py | 11 +- tableauserverclient/models/job_item.py | 112 +- tableauserverclient/models/metric_item.py | 160 +++ tableauserverclient/models/pagination_item.py | 14 +- .../models/permissions_item.py | 28 +- .../models/personal_access_token_auth.py | 4 +- tableauserverclient/models/project_item.py | 57 +- .../models/property_decorators.py | 1 + tableauserverclient/models/revision_item.py | 82 ++ tableauserverclient/models/schedule_item.py | 67 +- .../models/server_info_item.py | 15 +- tableauserverclient/models/site_item.py | 330 ++--- .../models/subscription_item.py | 36 +- tableauserverclient/models/table_item.py | 6 +- tableauserverclient/models/tableau_auth.py | 8 +- tableauserverclient/models/tag_item.py | 8 +- tableauserverclient/models/task_item.py | 8 +- tableauserverclient/models/user_item.py | 80 +- tableauserverclient/models/view_item.py | 96 +- tableauserverclient/models/webhook_item.py | 29 +- tableauserverclient/models/workbook_item.py | 105 +- tableauserverclient/namespace.py | 5 +- tableauserverclient/py.typed | 0 tableauserverclient/server/__init__.py | 29 +- .../server/endpoint/__init__.py | 13 +- .../server/endpoint/auth_endpoint.py | 14 +- .../data_acceleration_report_endpoint.py | 7 +- .../server/endpoint/data_alert_endpoint.py | 62 +- .../server/endpoint/databases_endpoint.py | 9 +- .../server/endpoint/datasources_endpoint.py | 210 +++- .../endpoint/default_permissions_endpoint.py | 36 +- .../server/endpoint/dqw_endpoint.py | 4 +- .../server/endpoint/endpoint.py | 28 +- .../server/endpoint/exceptions.py | 10 +- .../server/endpoint/favorites_endpoint.py | 46 +- .../server/endpoint/fileuploads_endpoint.py | 5 +- .../server/endpoint/flow_runs_endpoint.py | 30 +- .../server/endpoint/flows_endpoint.py | 78 +- .../server/endpoint/groups_endpoint.py | 37 +- .../server/endpoint/jobs_endpoint.py | 28 +- .../server/endpoint/metadata_endpoint.py | 6 +- .../server/endpoint/metrics_endpoint.py | 78 ++ .../server/endpoint/permissions_endpoint.py | 22 +- .../server/endpoint/projects_endpoint.py | 37 +- .../server/endpoint/resource_tagger.py | 7 +- .../server/endpoint/schedules_endpoint.py | 133 +- .../server/endpoint/server_info_endpoint.py | 3 +- .../server/endpoint/sites_endpoint.py | 33 +- .../server/endpoint/subscriptions_endpoint.py | 21 +- .../server/endpoint/tables_endpoint.py | 9 +- .../server/endpoint/tasks_endpoint.py | 4 +- .../server/endpoint/users_endpoint.py | 33 +- .../server/endpoint/views_endpoint.py | 62 +- .../server/endpoint/webhooks_endpoint.py | 26 +- .../server/endpoint/workbooks_endpoint.py | 238 +++- tableauserverclient/server/query.py | 68 +- tableauserverclient/server/request_factory.py | 229 ++-- tableauserverclient/server/request_options.py | 1 + tableauserverclient/server/server.py | 27 +- test/_utils.py | 12 +- test/assets/datasource_revision.xml | 14 + test/assets/flow_get_by_id.xml | 10 + test/assets/metrics_get.xml | 33 + test/assets/metrics_get_by_id.xml | 16 + test/assets/metrics_update.xml | 16 + test/assets/populate_excel.xlsx | Bin 0 -> 6623 bytes test/assets/populate_powerpoint.pptx | Bin 0 -> 363648 bytes .../request_option_slicing_queryset.xml | 46 + test/assets/schedule_add_flow.xml | 9 + test/assets/schedule_get_by_id.xml | 4 + test/assets/workbook_revision.xml | 14 + test/test_auth.py | 130 +- test/test_data_acceleration_report.py | 14 +- test/test_dataalert.py | 135 +- test/test_database.py | 109 +- test/test_datasource.py | 771 ++++++------ test/test_datasource_model.py | 2 +- test/test_exponential_backoff.py | 8 +- test/test_favorites.py | 150 ++- test/test_filesys_helpers.py | 50 +- test/test_fileuploads.py | 51 +- test/test_flow.py | 172 +-- test/test_flowruns.py | 102 +- test/test_group.py | 301 ++--- test/test_group_model.py | 1 + test/test_job.py | 82 +- test/test_metadata.py | 92 +- test/test_metrics.py | 105 ++ test/test_pager.py | 60 +- test/test_project.py | 370 +++--- test/test_project_model.py | 1 + test/test_regression_tests.py | 41 +- test/test_request_option.py | 219 ++-- test/test_requests.py | 34 +- test/test_schedule.py | 168 ++- test/test_server_info.py | 54 +- test/test_site.py | 256 ++-- test/test_site_model.py | 1 + test/test_sort.py | 83 +- test/test_subscription.py | 85 +- test/test_table.py | 47 +- test/test_tableauauth_model.py | 10 +- test/test_task.py | 79 +- test/test_user.py | 278 ++--- test/test_user_model.py | 1 + test/test_view.py | 315 ++--- test/test_webhook.py | 64 +- test/test_workbook.py | 1091 ++++++++++------- test/test_workbook_model.py | 1 + 165 files changed, 6657 insertions(+), 4292 deletions(-) create mode 100644 .github/workflows/meta-checks.yml create mode 100644 .github/workflows/publish-pypi.yml create mode 100644 samples/extracts.py delete mode 100644 samples/set_http_options.py create mode 100644 tableauserverclient/models/metric_item.py create mode 100644 tableauserverclient/models/revision_item.py create mode 100644 tableauserverclient/py.typed create mode 100644 tableauserverclient/server/endpoint/metrics_endpoint.py create mode 100644 test/assets/datasource_revision.xml create mode 100644 test/assets/flow_get_by_id.xml create mode 100644 test/assets/metrics_get.xml create mode 100644 test/assets/metrics_get_by_id.xml create mode 100644 test/assets/metrics_update.xml create mode 100644 test/assets/populate_excel.xlsx create mode 100644 test/assets/populate_powerpoint.pptx create mode 100644 test/assets/request_option_slicing_queryset.xml create mode 100644 test/assets/schedule_add_flow.xml create mode 100644 test/assets/schedule_get_by_id.xml create mode 100644 test/assets/workbook_revision.xml create mode 100644 test/test_metrics.py diff --git a/.github/workflows/meta-checks.yml b/.github/workflows/meta-checks.yml new file mode 100644 index 000000000..7ae27e6b8 --- /dev/null +++ b/.github/workflows/meta-checks.yml @@ -0,0 +1,35 @@ +name: types and style checks + +on: [push, pull_request] + +jobs: + build: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ['3.10'] + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e .[test] + + - name: Format with black + run: | + black --check --line-length 120 tableauserverclient samples test + + - name: Run Mypy tests + if: always() + run: | + mypy --show-error-codes --disable-error-code misc --disable-error-code import tableauserverclient test diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml new file mode 100644 index 000000000..2b3b8fa3e --- /dev/null +++ b/.github/workflows/publish-pypi.yml @@ -0,0 +1,37 @@ +name: Publish to PyPi + +# This will publish a package to TestPyPi (and real Pypi if run on master) with a version +# number generated by versioneer from the most recent tag looking like v____ +# TODO: maybe move this into the package job so all release-based actions are together +on: + workflow_dispatch: + push: + branches: + - master + +jobs: + build-n-publish: + name: Build dist files for PyPi + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: actions/setup-python@v1 + with: + python-version: 3.8 + - name: Build dist files + run: | + python -m pip install --upgrade pip + pip install -e .[test] + python setup.py sdist --formats=gztar + - name: Publish distribution 📦 to Test PyPI + uses: pypa/gh-action-pypi-publish@release/v1 # license BSD-2 + with: + password: ${{ secrets.TEST_PYPI_API_TOKEN }} + repository_url: https://test.pypi.org/legacy/ + - name: Publish distribution 📦 to PyPI + if: github.ref == 'refs/heads/master' + uses: pypa/gh-action-pypi-publish@release/v1 # license BSD-2 + with: + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 819cbb902..9fe99f953 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -1,6 +1,6 @@ name: Python tests -on: [push] +on: [push, pull_request] jobs: build: @@ -24,13 +24,12 @@ jobs: run: | python -m pip install --upgrade pip pip install -e .[test] - pip install mypy - name: Test with pytest + if: always() run: | pytest test - - name: Run Mypy but allow failures + - name: Run Mypy tests run: | - mypy --show-error-codes --disable-error-code misc tableauserverclient - continue-on-error: true + mypy --show-error-codes --disable-error-code misc --disable-error-code import tableauserverclient test diff --git a/.github/workflows/slack.yml b/.github/workflows/slack.yml index 05671333f..b11f4009a 100644 --- a/.github/workflows/slack.yml +++ b/.github/workflows/slack.yml @@ -9,6 +9,7 @@ jobs: name: Sends a message to Slack when a push, a pull request or an issue is made steps: - name: Send message to Slack API + continue-on-error: true uses: archive/github-actions-slack@v2.2.2 id: notify with: diff --git a/CHANGELOG.md b/CHANGELOG.md index f5c753cdd..c018294d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ + +## 0.18.0 (6 April 2022) +* Switched to using defused_xml for xml attack protection +* added linting and type hints +* improve experience with self-signed certificates/invalid ssl +* updated samples +* new item types: metrics, revisions for datasources and workbooks +* features: support adding flows to schedules, exporting workbooks to powerpoint +* fixes: delete extracts + ## 0.17.0 (20 October 2021) * Added support for accepting parameters for post request of the metadata api (#850) * Fixed jobs.get_by_id(job_id) example & reference docs (#867, #868) diff --git a/MANIFEST.in b/MANIFEST.in index b4b1425f3..c9bb30ee7 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -15,9 +15,6 @@ recursive-include test *.json recursive-include test *.pdf recursive-include test *.png recursive-include test *.py -recursive-include test *.tde -recursive-include test *.tds -recursive-include test *.tdsx -recursive-include test *.twb -recursive-include test *.twbx recursive-include test *.xml +global-include *.pyi +global-include *.typed \ No newline at end of file diff --git a/README.md b/README.md index b454dd4c7..f14c23230 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,9 @@ Use the Tableau Server Client (TSC) library to increase your productivity as you * Create users and groups. * Query projects, sites, and more. -This repository contains Python source code and sample files. Python versions 3.6 and up are supported. +This repository contains Python source code for the library and sample files showing how to use it. Python versions 3.6 and up are supported. + +To see sample code that works directly with the REST API (in Java, Python, or Postman), visit the [REST API Samples](https://github.com/tableau/rest-api-samples) repo. For more information on installing and using TSC, see the documentation: diff --git a/contributing.md b/contributing.md index 3d5cd3d43..c5f0fa95e 100644 --- a/contributing.md +++ b/contributing.md @@ -62,14 +62,23 @@ python setup.py build python setup.py test ``` +### To use your locally built version +```shell +pip install . +``` + ### Before Committing Our CI runs include a Python lint run, so you should run this locally and fix complaints before committing as this will fail your checkin. ```shell # this will run the formatter without making changes -black --line-length 120 tableauserverclient --check +black --line-length 120 tableauserverclient test samples --check # this will format the directory and code for you -black --line-length 120 tableauserverclient +black --line-length 120 tableauserverclient test samples + +# this will run type checking +pip install mypy +mypy --show-error-codes --disable-error-code misc --disable-error-code import tableauserverclient test ``` diff --git a/samples/add_default_permission.py b/samples/add_default_permission.py index 8018c7b30..56d3afdf1 100644 --- a/samples/add_default_permission.py +++ b/samples/add_default_permission.py @@ -16,16 +16,23 @@ def main(): - parser = argparse.ArgumentParser(description='Add workbook default permissions for a given project.') + parser = argparse.ArgumentParser(description="Add workbook default permissions for a given project.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - 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)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, 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 @@ -53,10 +60,7 @@ def main(): new_capabilities = {TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Allow} # Each PermissionRule in the list contains a grantee and a dict of capabilities - new_rules = [TSC.PermissionsRule( - grantee=default_permissions.grantee, - capabilities=new_capabilities - )] + new_rules = [TSC.PermissionsRule(grantee=default_permissions.grantee, capabilities=new_capabilities)] new_default_permissions = server.projects.update_workbook_default_permissions(project, new_rules) @@ -78,5 +82,5 @@ def main(): # server.projects.delete(project.id) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/create_group.py b/samples/create_group.py index ad0e6cc4f..16016398d 100644 --- a/samples/create_group.py +++ b/samples/create_group.py @@ -16,16 +16,23 @@ def main(): - parser = argparse.ArgumentParser(description='Creates a sample user group.') + parser = argparse.ArgumentParser(description="Creates a sample user group.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - 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)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, 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 @@ -38,10 +45,10 @@ def main(): tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - group = TSC.GroupItem('test') + group = TSC.GroupItem("test") group = server.groups.create(group) print(group) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/create_project.py b/samples/create_project.py index 814d35617..6271f3d93 100644 --- a/samples/create_project.py +++ b/samples/create_project.py @@ -14,27 +14,34 @@ import tableauserverclient as TSC -def create_project(server, project_item): +def create_project(server, project_item, samples=False): try: - project_item = server.projects.create(project_item) - print('Created a new project called: %s' % project_item.name) + project_item = server.projects.create(project_item, samples) + print("Created a new project called: %s" % project_item.name) return project_item except TSC.ServerResponseError: - print('We have already created this project: %s' % project_item.name) + print("We have already created this project: %s" % project_item.name) sys.exit(1) def main(): - parser = argparse.ArgumentParser(description='Create new projects.') + parser = argparse.ArgumentParser(description="Create new projects.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - 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)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, 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 @@ -45,23 +52,27 @@ def main(): 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=True) + server = TSC.Server(args.server) + server.use_server_version() with server.auth.sign_in(tableau_auth): # Use highest Server REST API version available server.use_server_version() # Without parent_id specified, projects are created at the top level. - top_level_project = TSC.ProjectItem(name='Top Level Project') + top_level_project = TSC.ProjectItem(name="Top Level Project") top_level_project = create_project(server, top_level_project) # Specifying parent_id creates a nested projects. - child_project = TSC.ProjectItem(name='Child Project', parent_id=top_level_project.id) - child_project = create_project(server, child_project) + child_project = TSC.ProjectItem(name="Child Project", parent_id=top_level_project.id) + child_project = create_project(server, child_project, samples=True) # Projects can be nested at any level. - grand_child_project = TSC.ProjectItem(name='Grand Child Project', parent_id=child_project.id) + grand_child_project = TSC.ProjectItem(name="Grand Child Project", parent_id=child_project.id) grand_child_project = create_project(server, grand_child_project) + # Projects can be updated + changed_project = server.projects.update(grand_child_project, samples=True) -if __name__ == '__main__': + +if __name__ == "__main__": main() diff --git a/samples/create_schedules.py b/samples/create_schedules.py index 39332713b..4fe6db5a4 100644 --- a/samples/create_schedules.py +++ b/samples/create_schedules.py @@ -16,17 +16,24 @@ def main(): - parser = argparse.ArgumentParser(description='Creates sample schedules for each type of frequency.') + 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', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - 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 + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, 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() @@ -36,47 +43,89 @@ def main(): 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=True) + 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): # Hourly Schedule # This schedule will run every 2 hours between 2:30AM and 11:00PM - hourly_interval = TSC.HourlyInterval(start_time=time(2, 30), - end_time=time(23, 0), - interval_value=2) - - hourly_schedule = TSC.ScheduleItem("Hourly-Schedule", 50, TSC.ScheduleItem.Type.Extract, - TSC.ScheduleItem.ExecutionOrder.Parallel, hourly_interval) - hourly_schedule = server.schedules.create(hourly_schedule) - print("Hourly schedule created (ID: {}).".format(hourly_schedule.id)) + hourly_interval = TSC.HourlyInterval(start_time=time(2, 30), end_time=time(23, 0), interval_value=2) + + hourly_schedule = TSC.ScheduleItem( + "Hourly-Schedule", + 50, + TSC.ScheduleItem.Type.Extract, + TSC.ScheduleItem.ExecutionOrder.Parallel, + hourly_interval, + ) + try: + hourly_schedule = server.schedules.create(hourly_schedule) + print("Hourly schedule created (ID: {}).".format(hourly_schedule.id)) + except Exception as e: + print(e) # Daily Schedule # This schedule will run every day at 5AM daily_interval = TSC.DailyInterval(start_time=time(5)) - daily_schedule = TSC.ScheduleItem("Daily-Schedule", 60, TSC.ScheduleItem.Type.Subscription, - TSC.ScheduleItem.ExecutionOrder.Serial, daily_interval) - daily_schedule = server.schedules.create(daily_schedule) - print("Daily schedule created (ID: {}).".format(daily_schedule.id)) + daily_schedule = TSC.ScheduleItem( + "Daily-Schedule", + 60, + TSC.ScheduleItem.Type.Subscription, + TSC.ScheduleItem.ExecutionOrder.Serial, + daily_interval, + ) + try: + daily_schedule = server.schedules.create(daily_schedule) + print("Daily schedule created (ID: {}).".format(daily_schedule.id)) + except Exception as e: + print(e) # Weekly Schedule # This schedule will wun every Monday, Wednesday, and Friday at 7:15PM - weekly_interval = TSC.WeeklyInterval(time(19, 15), - TSC.IntervalItem.Day.Monday, - TSC.IntervalItem.Day.Wednesday, - TSC.IntervalItem.Day.Friday) - weekly_schedule = TSC.ScheduleItem("Weekly-Schedule", 70, TSC.ScheduleItem.Type.Extract, - TSC.ScheduleItem.ExecutionOrder.Serial, weekly_interval) - weekly_schedule = server.schedules.create(weekly_schedule) - print("Weekly schedule created (ID: {}).".format(weekly_schedule.id)) + weekly_interval = TSC.WeeklyInterval( + time(19, 15), TSC.IntervalItem.Day.Monday, TSC.IntervalItem.Day.Wednesday, TSC.IntervalItem.Day.Friday + ) + weekly_schedule = TSC.ScheduleItem( + "Weekly-Schedule", + 70, + TSC.ScheduleItem.Type.Extract, + TSC.ScheduleItem.ExecutionOrder.Serial, + weekly_interval, + ) + try: + weekly_schedule = server.schedules.create(weekly_schedule) + print("Weekly schedule created (ID: {}).".format(weekly_schedule.id)) + except Exception as e: + print(e) + options = TSC.RequestOptions() + options.filter.add( + TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, "Weekly Schedule") + ) + schedules, _ = server.schedules.get(req_options=options) + weekly_schedule = schedules[0] + print(weekly_schedule) # 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("Monthly-Schedule", 80, TSC.ScheduleItem.Type.Subscription, - TSC.ScheduleItem.ExecutionOrder.Parallel, monthly_interval) - monthly_schedule = server.schedules.create(monthly_schedule) - print("Monthly schedule created (ID: {}).".format(monthly_schedule.id)) - - -if __name__ == '__main__': + monthly_interval = TSC.MonthlyInterval(start_time=time(23, 30), interval_value=15) + monthly_schedule = TSC.ScheduleItem( + "Monthly-Schedule", + 80, + TSC.ScheduleItem.Type.Subscription, + TSC.ScheduleItem.ExecutionOrder.Parallel, + monthly_interval, + ) + try: + monthly_schedule = server.schedules.create(monthly_schedule) + print("Monthly schedule created (ID: {}).".format(monthly_schedule.id)) + except Exception as e: + print(e) + + # Now fetch the weekly schedule by id + fetched_schedule = server.schedules.get_by_id(weekly_schedule.id) + fetched_interval = fetched_schedule.interval_item + print("Fetched back our weekly schedule, it shows interval ", fetched_interval) + + +if __name__ == "__main__": main() diff --git a/samples/download_view_image.py b/samples/download_view_image.py index 3ac2ed4d5..3b2fbac1c 100644 --- a/samples/download_view_image.py +++ b/samples/download_view_image.py @@ -16,21 +16,27 @@ def main(): - parser = argparse.ArgumentParser(description='Download image of a specified view.') + parser = argparse.ArgumentParser(description="Download image of a specified view.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - 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)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, 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 - parser.add_argument('--view-name', '-vn', required=True, - help='name of view to download an image of') - parser.add_argument('--filepath', '-f', required=True, help='filepath to save the image returned') - parser.add_argument('--maxage', '-m', required=False, help='max age of the image in the cache in minutes.') + parser.add_argument("--view-name", "-vn", required=True, help="name of view to download an image of") + parser.add_argument("--filepath", "-f", required=True, help="filepath to save the image returned") + parser.add_argument("--maxage", "-m", required=False, help="max age of the image in the cache in minutes.") args = parser.parse_args() @@ -44,8 +50,9 @@ def main(): with server.auth.sign_in(tableau_auth): # Step 2: Query for the view that we want an image of req_option = TSC.RequestOptions() - req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Operator.Equals, args.view_name)) + req_option.filter.add( + TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, args.view_name) + ) all_views, pagination_item = server.views.get(req_option) if not all_views: raise LookupError("View with the specified name was not found.") @@ -55,8 +62,9 @@ def main(): if not max_age: max_age = 1 - image_req_option = TSC.ImageRequestOptions(imageresolution=TSC.ImageRequestOptions.Resolution.High, - maxage=max_age) + image_req_option = TSC.ImageRequestOptions( + imageresolution=TSC.ImageRequestOptions.Resolution.High, maxage=max_age + ) server.views.populate_image(view_item, image_req_option) with open(args.filepath, "wb") as image_file: @@ -65,5 +73,5 @@ def main(): print("View image saved to {0}".format(args.filepath)) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/explore_datasource.py b/samples/explore_datasource.py index a78345122..014a274ef 100644 --- a/samples/explore_datasource.py +++ b/samples/explore_datasource.py @@ -17,19 +17,26 @@ def main(): - parser = argparse.ArgumentParser(description='Explore datasource functions supported by the Server API.') + parser = argparse.ArgumentParser(description="Explore datasource functions supported by the Server API.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - 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)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, 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 - parser.add_argument('--publish', metavar='FILEPATH', help='path to datasource to publish') - parser.add_argument('--download', metavar='FILEPATH', help='path to save downloaded datasource') + parser.add_argument("--publish", metavar="FILEPATH", help="path to datasource to publish") + parser.add_argument("--download", metavar="FILEPATH", help="path to save downloaded datasource") args = parser.parse_args() @@ -50,7 +57,8 @@ def main(): if default_project is not None: new_datasource = TSC.DatasourceItem(default_project.id) new_datasource = server.datasources.publish( - new_datasource, args.publish, TSC.Server.PublishMode.Overwrite) + new_datasource, args.publish, TSC.Server.PublishMode.Overwrite + ) print("Datasource published. ID: {}".format(new_datasource.id)) else: print("Publish failed. Could not find the default project.") @@ -67,12 +75,16 @@ def main(): # Populate connections server.datasources.populate_connections(sample_datasource) print("\nConnections for {}: ".format(sample_datasource.name)) - print(["{0}({1})".format(connection.id, connection.datasource_name) - for connection in sample_datasource.connections]) + print( + [ + "{0}({1})".format(connection.id, connection.datasource_name) + for connection in sample_datasource.connections + ] + ) # Add some tags to the datasource original_tag_set = set(sample_datasource.tags) - sample_datasource.tags.update('a', 'b', 'c', 'd') + sample_datasource.tags.update("a", "b", "c", "d") server.datasources.update(sample_datasource) print("\nOld tag set: {}".format(original_tag_set)) print("New tag set: {}".format(sample_datasource.tags)) @@ -82,5 +94,5 @@ def main(): server.datasources.update(sample_datasource) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/explore_webhooks.py b/samples/explore_webhooks.py index 50c677cba..764fb0904 100644 --- a/samples/explore_webhooks.py +++ b/samples/explore_webhooks.py @@ -18,19 +18,26 @@ def main(): - parser = argparse.ArgumentParser(description='Explore webhook functions supported by the Server API.') + parser = argparse.ArgumentParser(description="Explore webhook functions supported by the Server API.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - 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)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, 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 - parser.add_argument('--create', help='create a webhook') - parser.add_argument('--delete', help='delete a webhook', action='store_true') + parser.add_argument("--create", help="create a webhook") + parser.add_argument("--delete", help="delete a webhook", action="store_true") args = parser.parse_args() @@ -63,12 +70,12 @@ def main(): # Pick one webhook from the list and delete it sample_webhook = all_webhooks[0] # sample_webhook.delete() - print("+++"+sample_webhook.name) + print("+++" + sample_webhook.name) - if (args.delete): + if args.delete: print("Deleting webhook " + sample_webhook.name) server.webhooks.delete(sample_webhook.id) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/explore_workbook.py b/samples/explore_workbook.py index 8746db80e..a5a337653 100644 --- a/samples/explore_workbook.py +++ b/samples/explore_workbook.py @@ -18,21 +18,29 @@ def main(): - parser = argparse.ArgumentParser(description='Explore workbook functions supported by the Server API.') + parser = argparse.ArgumentParser(description="Explore workbook functions supported by the Server API.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - 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)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, 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 - parser.add_argument('--publish', metavar='FILEPATH', help='path to workbook to publish') - parser.add_argument('--download', metavar='FILEPATH', help='path to save downloaded workbook') - parser.add_argument('--preview-image', '-i', metavar='FILENAME', - help='filename (a .png file) to save the preview image') + parser.add_argument("--publish", metavar="FILEPATH", help="path to workbook to publish") + parser.add_argument("--download", metavar="FILEPATH", help="path to save downloaded workbook") + parser.add_argument( + "--preview-image", "-i", metavar="FILENAME", help="filename (a .png file) to save the preview image" + ) args = parser.parse_args() @@ -56,7 +64,7 @@ def main(): new_workbook = server.workbooks.publish(new_workbook, args.publish, overwrite_true) print("Workbook published. ID: {}".format(new_workbook.id)) else: - print('Publish failed. Could not find the default project.') + print("Publish failed. Could not find the default project.") # Gets all workbook items all_workbooks, pagination_item = server.workbooks.get() @@ -75,12 +83,16 @@ def main(): # Populate connections server.workbooks.populate_connections(sample_workbook) print("\nConnections for {}: ".format(sample_workbook.name)) - print(["{0}({1})".format(connection.id, connection.datasource_name) - for connection in sample_workbook.connections]) + print( + [ + "{0}({1})".format(connection.id, connection.datasource_name) + for connection in sample_workbook.connections + ] + ) # Update tags and show_tabs flag original_tag_set = set(sample_workbook.tags) - sample_workbook.tags.update('a', 'b', 'c', 'd') + sample_workbook.tags.update("a", "b", "c", "d") sample_workbook.show_tabs = True server.workbooks.update(sample_workbook) print("\nWorkbook's old tag set: {}".format(original_tag_set)) @@ -111,10 +123,10 @@ def main(): if args.preview_image: # Populate workbook preview image server.workbooks.populate_preview_image(sample_workbook) - with open(args.preview_image, 'wb') as f: + with open(args.preview_image, "wb") as f: f.write(sample_workbook.preview_image) print("\nDownloaded preview image of workbook to {}".format(os.path.abspath(args.preview_image))) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/export.py b/samples/export.py index 6317ec53b..701f93fee 100644 --- a/samples/export.py +++ b/samples/export.py @@ -12,29 +12,38 @@ def main(): - parser = argparse.ArgumentParser(description='Export a view as an image, PDF, or CSV') + parser = argparse.ArgumentParser(description="Export a view as an image, PDF, or CSV") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - 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)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, 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 group = parser.add_mutually_exclusive_group(required=True) - group.add_argument('--pdf', dest='type', action='store_const', const=('populate_pdf', 'PDFRequestOptions', 'pdf', - 'pdf')) - group.add_argument('--png', dest='type', action='store_const', const=('populate_image', 'ImageRequestOptions', - 'image', 'png')) - group.add_argument('--csv', dest='type', action='store_const', const=('populate_csv', 'CSVRequestOptions', 'csv', - 'csv')) + group.add_argument( + "--pdf", dest="type", action="store_const", const=("populate_pdf", "PDFRequestOptions", "pdf", "pdf") + ) + group.add_argument( + "--png", dest="type", action="store_const", const=("populate_image", "ImageRequestOptions", "image", "png") + ) + group.add_argument( + "--csv", dest="type", action="store_const", const=("populate_csv", "CSVRequestOptions", "csv", "csv") + ) - parser.add_argument('--file', '-f', help='filename to store the exported data') - parser.add_argument('--filter', '-vf', metavar='COLUMN:VALUE', - help='View filter to apply to the view') - parser.add_argument('resource_id', help='LUID for the view') + parser.add_argument("--file", "-f", help="filename to store the exported data") + parser.add_argument("--filter", "-vf", metavar="COLUMN:VALUE", help="View filter to apply to the view") + parser.add_argument("resource_id", help="LUID for the view") args = parser.parse_args() @@ -45,9 +54,8 @@ def main(): tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - views = filter(lambda x: x.id == args.resource_id, - TSC.Pager(server.views.get)) - view = views.pop() + views = filter(lambda x: x.id == args.resource_id or x.name == args.resource_id, TSC.Pager(server.views.get)) + view = list(views).pop() # in python 3 filter() returns a filter object # We have a number of different types and functions for each different export type. # We encode that information above in the const=(...) parameter to the add_argument function to make @@ -58,21 +66,22 @@ def main(): option_factory = getattr(TSC, option_factory_name) if args.filter: - options = option_factory().vf(*args.filter.split(':')) + options = option_factory().vf(*args.filter.split(":")) else: options = None if args.file: filename = args.file else: - filename = 'out.{}'.format(extension) + filename = "out.{}".format(extension) populate(view, options) - with file(filename, 'wb') as f: - if member_name == 'csv': + with open(filename, "wb") as f: + if member_name == "csv": f.writelines(getattr(view, member_name)) else: f.write(getattr(view, member_name)) + print("saved to " + filename) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/export_wb.py b/samples/export_wb.py index 2be476130..2376ee62b 100644 --- a/samples/export_wb.py +++ b/samples/export_wb.py @@ -16,11 +16,13 @@ import os.path import tableauserverclient as TSC + try: import PyPDF2 except ImportError: - print('Please `pip install PyPDF2` to use this sample') + print("Please `pip install PyPDF2` to use this sample") import sys + sys.exit(1) @@ -34,7 +36,7 @@ def download_pdf(server, tempdir, view): # -> Filename to downloaded pdf logging.info("Exporting {}".format(view.id)) destination_filename = os.path.join(tempdir, view.id) server.views.populate_pdf(view) - with file(destination_filename, 'wb') as f: + with file(destination_filename, "wb") as f: f.write(view.pdf) return destination_filename @@ -50,19 +52,26 @@ def cleanup(tempdir): def main(): - parser = argparse.ArgumentParser(description='Export to PDF all of the views in a workbook.') + parser = argparse.ArgumentParser(description="Export to PDF all of the views in a workbook.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - 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)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, 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 - parser.add_argument('--file', '-f', default='out.pdf', help='filename to store the exported data') - parser.add_argument('resource_id', help='LUID for the workbook') + parser.add_argument("--file", "-f", default="out.pdf", help="filename to store the exported data") + parser.add_argument("resource_id", help="LUID for the workbook") args = parser.parse_args() @@ -70,7 +79,7 @@ def main(): logging_level = getattr(logging, args.logging_level.upper()) logging.basicConfig(level=logging_level) - tempdir = tempfile.mkdtemp('tsc') + tempdir = tempfile.mkdtemp("tsc") logging.debug("Saving to tempdir: %s", tempdir) try: @@ -82,11 +91,11 @@ def main(): downloaded = (download(x) for x in get_list(args.resource_id)) output = reduce(combine_into, downloaded, PyPDF2.PdfFileMerger()) - with file(args.file, 'wb') as f: + with file(args.file, "wb") as f: output.write(f) finally: cleanup(tempdir) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/extracts.py b/samples/extracts.py new file mode 100644 index 000000000..e5879a825 --- /dev/null +++ b/samples/extracts.py @@ -0,0 +1,75 @@ +#### +# This script demonstrates how to use the Tableau Server Client +# to interact with workbooks. It explores the different +# functions that the Server API supports on workbooks. +# +# With no flags set, this sample will query all workbooks, +# pick one workbook and populate its connections/views, and update +# the workbook. Adding flags will demonstrate the specific feature +# on top of the general operations. +#### + +import argparse +import logging +import os.path + +import tableauserverclient as TSC + + +def main(): + + parser = argparse.ArgumentParser(description="Explore extract functions supported by the Server API.") + # Common options; please keep those in sync across all samples + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", help="site name") + parser.add_argument( + "--token-name", "-tn", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-tv", required=True, 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 + parser.add_argument("--delete") + parser.add_argument("--create") + 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) + + # SIGN IN + 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): + + # Gets all workbook items + 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 one workbook from the list + wb = all_workbooks[3] + + if args.create: + print("create extract on wb ", wb.name) + extract_job = server.workbooks.create_extract(wb, includeAll=True) + print(extract_job) + + if args.delete: + print("delete extract on wb ", wb.name) + jj = server.workbooks.delete_extract(wb) + print(jj) + + +if __name__ == "__main__": + main() diff --git a/samples/filter_sort_groups.py b/samples/filter_sort_groups.py index 24dee791d..e4f2c2bee 100644 --- a/samples/filter_sort_groups.py +++ b/samples/filter_sort_groups.py @@ -8,31 +8,39 @@ import argparse import logging +import urllib.parse import tableauserverclient as TSC -def create_example_group(group_name='Example Group', server=None): +def create_example_group(group_name="Example Group", server=None): new_group = TSC.GroupItem(group_name) try: new_group = server.groups.create(new_group) - print('Created a new project called: \'%s\'' % group_name) + print("Created a new project called: '%s'" % group_name) print(new_group) except TSC.ServerResponseError: - print('Group \'%s\' already existed' % group_name) + print("Group '%s' already existed" % group_name) def main(): - parser = argparse.ArgumentParser(description='Filter and sort groups.') + parser = argparse.ArgumentParser(description="Filter and sort groups.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - 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)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, 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 @@ -46,21 +54,21 @@ def main(): server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - group_name = 'SALES NORTHWEST' + group_name = "SALES NORTHWEST" # Try to create a group named "SALES NORTHWEST" create_example_group(group_name, server) - group_name = 'SALES ROMANIA' + group_name = "SALES ROMANIA" # Try to create a group named "SALES ROMANIA" create_example_group(group_name, server) # URL Encode the name of the group that we want to filter on # i.e. turn spaces into plus signs - filter_group_name = 'SALES+ROMANIA' + filter_group_name = urllib.parse.quote_plus(group_name) options = TSC.RequestOptions() - options.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Operator.Equals, - filter_group_name)) + options.filter.add( + TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, filter_group_name) + ) filtered_groups, _ = server.groups.get(req_options=options) # Result can either be a matching group or an empty list @@ -71,19 +79,37 @@ def main(): error = "No project named '{}' found".format(filter_group_name) print(error) + # Or, try the above with the django style filtering + try: + group = server.groups.filter(name=filter_group_name)[0] + except IndexError: + print(f"No project named '{filter_group_name}' found") + else: + print(group.name) + options = TSC.RequestOptions() - options.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Operator.In, - ['SALES+NORTHWEST', 'SALES+ROMANIA', 'this_group'])) + options.filter.add( + TSC.Filter( + TSC.RequestOptions.Field.Name, + TSC.RequestOptions.Operator.In, + ["SALES+NORTHWEST", "SALES+ROMANIA", "this_group"], + ) + ) - options.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Direction.Desc)) + options.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Direction.Desc)) matching_groups, pagination_item = server.groups.get(req_options=options) - print('Filtered groups are:') + print("Filtered groups are:") for group in matching_groups: print(group.name) + # or, try the above with the django style filtering. + + groups = ["SALES NORTHWEST", "SALES ROMANIA", "this_group"] + groups = [urllib.parse.quote_plus(group) for group in groups] + for group in server.groups.filter(name__in=groups).sort("-name"): + print(group.name) + -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/filter_sort_projects.py b/samples/filter_sort_projects.py index 23b350fa6..628b1c972 100644 --- a/samples/filter_sort_projects.py +++ b/samples/filter_sort_projects.py @@ -7,33 +7,44 @@ import argparse import logging +import urllib.parse import tableauserverclient as TSC -def create_example_project(name='Example Project', content_permissions='LockedToProject', - description='Project created for testing', server=None): +def create_example_project( + name="Example Project", + content_permissions="LockedToProject", + description="Project created for testing", + server=None, +): - new_project = TSC.ProjectItem(name=name, content_permissions=content_permissions, - description=description) + new_project = TSC.ProjectItem(name=name, content_permissions=content_permissions, description=description) try: server.projects.create(new_project) - print('Created a new project called: %s' % name) + print("Created a new project called: %s" % name) except TSC.ServerResponseError: - print('We have already created this resource: %s' % name) + print("We have already created this resource: %s" % name) def main(): - parser = argparse.ArgumentParser(description='Filter and sort projects.') + parser = argparse.ArgumentParser(description="Filter and sort projects.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - 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)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, 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 @@ -49,12 +60,12 @@ def main(): # Use highest Server REST API version available server.use_server_version() - filter_project_name = 'default' + filter_project_name = "default" options = TSC.RequestOptions() - options.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Operator.Equals, - filter_project_name)) + options.filter.add( + TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, filter_project_name) + ) filtered_projects, _ = server.projects.get(req_options=options) # Result can either be a matching project or an empty list @@ -65,26 +76,33 @@ def main(): error = "No project named '{}' found".format(filter_project_name) print(error) - create_example_project(name='Example 1', server=server) - create_example_project(name='Example 2', server=server) - create_example_project(name='Example 3', server=server) - create_example_project(name='Proiect ca Exemplu', server=server) + create_example_project(name="Example 1", server=server) + create_example_project(name="Example 2", server=server) + create_example_project(name="Example 3", server=server) + create_example_project(name="Proiect ca Exemplu", server=server) options = TSC.RequestOptions() # don't forget to URL encode the query names - options.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Operator.In, - ['Example+1', 'Example+2', 'Example+3'])) + options.filter.add( + TSC.Filter( + TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.In, ["Example+1", "Example+2", "Example+3"] + ) + ) - options.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Direction.Desc)) + options.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Direction.Desc)) matching_projects, pagination_item = server.projects.get(req_options=options) - print('Filtered projects are:') + print("Filtered projects are:") for project in matching_projects: print(project.name, project.id) + # Or, try the django style filtering. + projects = ["Example 1", "Example 2", "Example 3"] + projects = [urllib.parse.quote_plus(p) for p in projects] + for project in server.projects.filter(name__in=projects).sort("-name"): + print(project.name, project.id) + -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/initialize_server.py b/samples/initialize_server.py index a7dd552e1..586011120 100644 --- a/samples/initialize_server.py +++ b/samples/initialize_server.py @@ -11,20 +11,27 @@ def main(): - parser = argparse.ArgumentParser(description='Initialize a server with content.') + parser = argparse.ArgumentParser(description="Initialize a server with content.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - 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)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, 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 - parser.add_argument('--datasources-folder', '-df', required=True, help='folder containing datasources') - parser.add_argument('--workbooks-folder', '-wf', required=True, help='folder containing workbooks') - parser.add_argument('--project', required=False, default='Default', help='project to use') + parser.add_argument("--datasources-folder", "-df", required=True, help="folder containing datasources") + parser.add_argument("--workbooks-folder", "-wf", required=True, help="folder containing workbooks") + parser.add_argument("--project", required=False, default="Default", help="project to use") args = parser.parse_args() @@ -50,8 +57,11 @@ def main(): # Create the site if it doesn't exist if existing_site is None: print("Site not found: {0} Creating it...").format(args.site_id) - new_site = TSC.SiteItem(name=args.site_id, content_url=args.site_id.replace(" ", ""), - admin_mode=TSC.SiteItem.AdminMode.ContentAndUsers) + new_site = TSC.SiteItem( + name=args.site_id, + content_url=args.site_id.replace(" ", ""), + admin_mode=TSC.SiteItem.AdminMode.ContentAndUsers, + ) server.sites.create(new_site) else: print("Site {0} exists. Moving on...").format(args.site_id) @@ -70,6 +80,7 @@ def main(): # Step 4: Create the project we need only if it doesn't exist ################################################################################ import time + time.sleep(2) # sad panda...something about eventually consistent model all_projects = TSC.Pager(server_upload.projects) project = next((p for p in all_projects if p.name.lower() == args.project.lower()), None) @@ -90,7 +101,7 @@ def main(): def publish_datasources_to_site(server_object, project, folder): - path = folder + '/*.tds*' + path = folder + "/*.tds*" for fname in glob.glob(path): new_ds = TSC.DatasourceItem(project.id) @@ -99,7 +110,7 @@ def publish_datasources_to_site(server_object, project, folder): def publish_workbooks_to_site(server_object, project, folder): - path = folder + '/*.twb*' + path = folder + "/*.twb*" for fname in glob.glob(path): new_workbook = TSC.WorkbookItem(project.id) diff --git a/samples/kill_all_jobs.py b/samples/kill_all_jobs.py index 196da4b01..02f19d976 100644 --- a/samples/kill_all_jobs.py +++ b/samples/kill_all_jobs.py @@ -11,16 +11,23 @@ def main(): - parser = argparse.ArgumentParser(description='Cancel all of the running background jobs.') + parser = argparse.ArgumentParser(description="Cancel all of the running background jobs.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - 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)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, 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 @@ -40,5 +47,5 @@ def main(): print(server.jobs.cancel(job.id), job.id, job.status, job.type) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/list.py b/samples/list.py index 867757668..db0b7c790 100644 --- a/samples/list.py +++ b/samples/list.py @@ -13,18 +13,25 @@ def main(): - parser = argparse.ArgumentParser(description='List out the names and LUIDs for different resource types.') + parser = argparse.ArgumentParser(description="List out the names and LUIDs for different resource types.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-n', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - 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)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-n", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, 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 - parser.add_argument('resource_type', choices=['workbook', 'datasource', 'project', 'view', 'job', 'webhooks']) + parser.add_argument("resource_type", choices=["workbook", "datasource", "project", "view", "job", "webhooks"]) args = parser.parse_args() @@ -37,17 +44,24 @@ def main(): server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): endpoint = { - 'workbook': server.workbooks, - 'datasource': server.datasources, - 'view': server.views, - 'job': server.jobs, - 'project': server.projects, - 'webhooks': server.webhooks, + "datasource": server.datasources, + "job": server.jobs, + "metric": server.metrics, + "project": server.projects, + "view": server.views, + "webhooks": server.webhooks, + "workbook": server.workbooks, }.get(args.resource_type) - for resource in TSC.Pager(endpoint.get): + options = TSC.RequestOptions() + options.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Direction.Desc)) + + count = 0 + for resource in TSC.Pager(endpoint.get, options): + count = count + 1 print(resource.id, resource.name) + print("Total: {}".format(count)) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/login.py b/samples/login.py index c8af97505..c459b9370 100644 --- a/samples/login.py +++ b/samples/login.py @@ -11,49 +11,73 @@ import tableauserverclient as TSC -def main(): - parser = argparse.ArgumentParser(description='Logs in to the server.') - # This command is special, as it doesn't take `token-value` and it offer both token-based and password based authentication. - # Please still try to keep common options like `server` and `site` consistent across samples - # Common options: - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - 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 - group = parser.add_mutually_exclusive_group(required=True) - group.add_argument('--username', '-u', help='username to sign into the server') - group.add_argument('--token-name', '-n', help='name of the personal access token used to sign into the server') - +# If a sample has additional arguments, then it should copy this code and insert them after the call to +# sample_define_common_options +# If it has no additional arguments, it can just call this method +def set_up_and_log_in(): + parser = argparse.ArgumentParser(description="Logs in to the server.") + sample_define_common_options(parser) 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) - # Make sure we use an updated version of the rest apis. - server = TSC.Server(args.server, use_server_version=True) + server = sample_connect_to_server(args) + print(server.server_info.get()) + print(server.server_address, "site:", server.site_id, "user:", server.user_id) + + +def sample_define_common_options(parser): + # Common options; please keep these in sync across all samples by copying or calling this method directly + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-t", help="site name") + auth = parser.add_mutually_exclusive_group(required=True) + auth.add_argument("--token-name", "-tn", help="name of the personal access token used to sign into the server") + auth.add_argument("--username", "-u", help="username to sign into the server") + + parser.add_argument("--token-value", "-tv", help="value of the personal access token used to sign into the server") + parser.add_argument("--password", "-p", help="value of the password 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)", + ) + + +def sample_connect_to_server(args): if args.username: # Trying to authenticate using username and password. - password = getpass.getpass("Password: ") + password = args.password or getpass.getpass("Password: ") - print("\nSigning in...\nServer: {}\nSite: {}\nUsername: {}".format(args.server, args.site, args.username)) tableau_auth = TSC.TableauAuth(args.username, password, site_id=args.site) - with server.auth.sign_in(tableau_auth): - print('Logged in successfully') + print("\nSigning in...\nServer: {}\nSite: {}\nUsername: {}".format(args.server, args.site, args.username)) else: # Trying to authenticate using personal access tokens. - personal_access_token = getpass.getpass("Personal Access Token: ") + token = args.token_value or getpass.getpass("Personal Access Token: ") + + tableau_auth = TSC.PersonalAccessTokenAuth( + token_name=args.token_name, personal_access_token=token, site_id=args.site + ) + print("\nSigning in...\nServer: {}\nSite: {}\nToken name: {}".format(args.server, args.site, args.token_name)) + + if not tableau_auth: + raise TabError("Did not create authentication object. Check arguments.") + + # Only set this to False if you are running against a server you trust AND you know why the cert is broken + check_ssl_certificate = True + + # Make sure we use an updated version of the rest apis, and pass in our cert handling choice + server = TSC.Server(args.server, use_server_version=True, http_options={"verify": check_ssl_certificate}) + server.auth.sign_in(tableau_auth) + print("Logged in successfully") - print("\nSigning in...\nServer: {}\nSite: {}\nToken name: {}" - .format(args.server, args.site, args.token_name)) - tableau_auth = TSC.PersonalAccessTokenAuth(token_name=args.token_name, - personal_access_token=personal_access_token, site_id=args.site) - with server.auth.sign_in_with_personal_access_token(tableau_auth): - print('Logged in successfully') + return server -if __name__ == '__main__': - main() +if __name__ == "__main__": + set_up_and_log_in() diff --git a/samples/metadata_query.py b/samples/metadata_query.py index c9cf7394c..65df9ddb0 100644 --- a/samples/metadata_query.py +++ b/samples/metadata_query.py @@ -12,19 +12,29 @@ def main(): - parser = argparse.ArgumentParser(description='Use the metadata API to get information on a published data source.') + parser = argparse.ArgumentParser(description="Use the metadata API to get information on a published data source.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-n', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - 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)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-n", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, 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 - parser.add_argument('datasource_name', nargs='?', help="The name of the published datasource. If not present, we query all data sources.") - + parser.add_argument( + "datasource_name", + nargs="?", + help="The name of the published datasource. If not present, we query all data sources.", + ) args = parser.parse_args() @@ -37,7 +47,8 @@ def main(): server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): # Execute the query - result = server.metadata.query(""" + result = server.metadata.query( + """ query useMetadataApiToQueryOrdersDatabases($name: String){ publishedDatasources (filter: {name: $name}) { luid @@ -48,17 +59,20 @@ def main(): name } } - }""", {"name": args.datasource_name}) + }""", + {"name": args.datasource_name}, + ) # Display warnings/errors (if any) if result.get("errors"): print("### Errors/Warnings:") pprint(result["errors"]) - + # Print the results if result.get("data"): print("### Results:") pprint(result["data"]["publishedDatasources"]) -if __name__ == '__main__': + +if __name__ == "__main__": main() diff --git a/samples/move_workbook_projects.py b/samples/move_workbook_projects.py index c8227aeda..22465925f 100644 --- a/samples/move_workbook_projects.py +++ b/samples/move_workbook_projects.py @@ -9,25 +9,33 @@ import argparse import logging +import urllib.parse import tableauserverclient as TSC def main(): - parser = argparse.ArgumentParser(description='Move one workbook from the default project to another.') + parser = argparse.ArgumentParser(description="Move one workbook from the default project to another.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - 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)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, 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 - parser.add_argument('--workbook-name', '-w', required=True, help='name of workbook to move') - parser.add_argument('--destination-project', '-d', required=True, help='name of project to move workbook into') + parser.add_argument("--workbook-name", "-w", required=True, help="name of workbook to move") + parser.add_argument("--destination-project", "-d", required=True, help="name of project to move workbook into") args = parser.parse_args() @@ -39,30 +47,23 @@ def main(): tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - # Step 2: Query workbook to move - req_option = TSC.RequestOptions() - req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Operator.Equals, args.workbook_name)) - all_workbooks, pagination_item = server.workbooks.get(req_option) + # Step 2: Find destination project + try: + dest_project = server.projects.filter(name=urllib.parse.quote_plus(args.destination_project))[0] + except IndexError: + raise LookupError(f"No project named {args.destination_project} found.") - # Step 3: Find destination project - all_projects, pagination_item = server.projects.get() - dest_project = next((project for project in all_projects if project.name == args.destination_project), None) + # Step 3: Query workbook to move + try: + workbook = server.workbooks.filter(name=urllib.parse.quote_plus(args.workbook_name))[0] + except IndexError: + raise LookupError(f"No workbook named {args.workbook_name} found") - if dest_project is not None: - # Step 4: Update workbook with new project id - if all_workbooks: - print("Old project: {}".format(all_workbooks[0].project_name)) - all_workbooks[0].project_id = dest_project.id - target_workbook = server.workbooks.update(all_workbooks[0]) - print("New project: {}".format(target_workbook.project_name)) - else: - error = "No workbook named {} found.".format(args.workbook_name) - raise LookupError(error) - else: - error = "No project named {} found.".format(args.destination_project) - raise LookupError(error) + # Step 4: Update workbook with new project id + workbook.project_id = dest_project.id + target_workbook = server.workbooks.update(workbook) + print(f"New project: {target_workbook.project_name}") -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index e0475ac06..c473712e4 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -17,22 +17,30 @@ def main(): - parser = argparse.ArgumentParser(description="Move one workbook from the" - "default project of the default site to" - "the default project of another site.") + parser = argparse.ArgumentParser( + description="Move one workbook from the" + "default project of the default site to" + "the default project of another site." + ) # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - 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)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, 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 - parser.add_argument('--workbook-name', '-w', required=True, help='name of workbook to move') - parser.add_argument('--destination-site', '-d', required=True, help='name of site to move workbook into') - + parser.add_argument("--workbook-name", "-w", required=True, help="name of workbook to move") + parser.add_argument("--destination-site", "-d", required=True, help="name of site to move workbook into") args = parser.parse_args() @@ -49,13 +57,14 @@ def main(): with source_server.auth.sign_in(tableau_auth): # Step 2: Query workbook to move req_option = TSC.RequestOptions() - req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Operator.Equals, args.workbook_name)) + req_option.filter.add( + TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, args.workbook_name) + ) all_workbooks, pagination_item = source_server.workbooks.get(req_option) # Step 3: Download workbook to a temp directory if len(all_workbooks) == 0: - print('No workbook named {} found.'.format(args.workbook_name)) + print("No workbook named {} found.".format(args.workbook_name)) else: tmpdir = tempfile.mkdtemp() try: @@ -63,8 +72,9 @@ def main(): # Step 4: Check if destination site exists, then sign in to the site all_sites, pagination_info = source_server.sites.get() - found_destination_site = any((True for site in all_sites if - args.destination_site.lower() == site.content_url.lower())) + found_destination_site = any( + (True for site in all_sites if args.destination_site.lower() == site.content_url.lower()) + ) if not found_destination_site: error = "No site named {} found.".format(args.destination_site) raise LookupError(error) @@ -78,8 +88,9 @@ def main(): # Step 5: Create a new workbook item and publish workbook. Note that # an empty project_id will publish to the 'Default' project. new_workbook = TSC.WorkbookItem(name=args.workbook_name, project_id="") - new_workbook = dest_server.workbooks.publish(new_workbook, workbook_path, - mode=TSC.Server.PublishMode.Overwrite) + new_workbook = dest_server.workbooks.publish( + new_workbook, workbook_path, mode=TSC.Server.PublishMode.Overwrite + ) print("Successfully moved {0} ({1})".format(new_workbook.name, new_workbook.id)) # Step 6: Delete workbook from source site and delete temp directory @@ -89,5 +100,5 @@ def main(): shutil.rmtree(tmpdir) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/pagination_sample.py b/samples/pagination_sample.py index 2ebd011dc..e194f59f5 100644 --- a/samples/pagination_sample.py +++ b/samples/pagination_sample.py @@ -1,11 +1,12 @@ #### -# This script demonstrates how to use pagination item that is returned as part -# of many of the .get() method calls. +# This script demonstrates how to work with pagination in the .get() method calls, and how to use +# the QuerySet item that is an alternative interface for filtering and sorting these calls. # -# This script will iterate over every workbook that exists on the server using the +# In Part 1, this script will iterate over every workbook that exists on the server using the # pagination item to fetch additional pages as needed. +# In Part 2, the script will iterate over the same workbooks with an easy-to-read filter. # -# While this sample uses workbook, this same technique will work with any of the .get() methods that return +# While this sample uses workbooks, this same technique will work with any of the .get() methods that return # a pagination item #### @@ -18,18 +19,25 @@ def main(): - parser = argparse.ArgumentParser(description='Demonstrate pagination on the list of workbooks on the server.') + parser = argparse.ArgumentParser(description="Demonstrate pagination on the list of workbooks on the server.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-n', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - 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 + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-n", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, 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: + # No additional options, yet. If you add some, please add them here args = parser.parse_args() @@ -41,32 +49,61 @@ def main(): tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - # Pager returns a generator that yields one item at a time fetching - # from Server only when necessary. Pager takes a server Endpoint as its - # first parameter. It will call 'get' on that endpoint. To get workbooks - # pass `server.workbooks`, to get users pass` server.users`, etc - # You can then loop over the generator to get the objects one at a time - # Here we print the workbook id for each workbook - + you = server.users.get_by_id(server.user_id) + print(you.name, you.id) + # 1. Pager: Pager takes a server Endpoint as its first parameter, and a RequestOptions + # object as the second parameter. The Endpoint defines which type of objects it returns, and + # RequestOptions defines any restrictions on the objects: filter by name, sort, or select a page print("Your server contains the following workbooks:\n") - for wb in TSC.Pager(server.workbooks): + count = 0 + # Using a small number here so that you can see it work. Default is 100 and mostly doesn't need to change + page_options = TSC.RequestOptions(1, 5) + print("Fetching workbooks in pages of 5") + for wb in TSC.Pager(server.workbooks, page_options): + print(wb.name) + count = count + 1 + print("Total: {}\n".format(count)) + + count = 0 + page_options = TSC.RequestOptions(2, 3) + print("Paging: start at the second page of workbooks, using pagesize = 3") + for wb in TSC.Pager(server.workbooks, page_options): print(wb.name) + count = count + 1 + print("Truncated Total: {}\n".format(count)) + + print("Your id: ", you.name, you.id, "\n") + count = 0 + filtered_page_options = TSC.RequestOptions(1, 3) + filter_owner = TSC.Filter("ownerEmail", TSC.RequestOptions.Operator.Equals, "jfitzgerald@tableau.com") + filtered_page_options.filter.add(filter_owner) + print("Fetching workbooks again, filtering by owner") + for wb in TSC.Pager(server.workbooks, filtered_page_options): + print(wb.name, " -- ", wb.owner_id) + count = count + 1 + print("Filtered Total: {}\n".format(count)) - # Pager can also be used in list comprehensions or generator expressions - # for compactness and easy filtering. Generator expressions will use less - # memory than list comprehsnsions. Consult the Python laguage documentation for - # best practices on which are best for your use case. Here we loop over the - # Pager and only keep workbooks where the name starts with the letter 'a' - # >>> [wb for wb in TSC.Pager(server.workbooks) if wb.name.startswith('a')] # List Comprehension - # >>> (wb for wb in TSC.Pager(server.workbooks) if wb.name.startswith('a')) # Generator Expression + # 2. QuerySet offers a fluent interface on top of the RequestOptions object + print("Fetching workbooks again - this time filtered with QuerySet") + count = 0 + page = 1 + more = True + while more: + queryset = server.workbooks.filter(ownerEmail="jfitzgerald@tableau.com") + for wb in queryset.paginate(page_number=page, page_size=3): + print(wb.name, " -- ", wb.owner_id) + count = count + 1 + more = queryset.total_available > count + page = page + 1 + print("QuerySet Total: {}".format(count)) - # Since Pager is a generator it follows the standard conventions and can - # be fed to a list if you really need all the workbooks in memory at once. - # If you need everything, it may be faster to use a larger page size + # 3. QuerySet also allows you to iterate over all objects without explicitly paging. + print("Fetching again - this time without manually paging") + for i, wb in enumerate(server.workbooks.filter(owner_email="jfitzgerald@tableau.com"), start=1): + print(wb.name, "--", wb.owner_id) - # >>> request_options = TSC.RequestOptions(pagesize=1000) - # >>> all_workbooks = list(TSC.Pager(server.workbooks, request_options)) + print(f"QuerySet Total, implicit paging: {i}") -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/publish_datasource.py b/samples/publish_datasource.py index 8ae744185..ad929fd99 100644 --- a/samples/publish_datasource.py +++ b/samples/publish_datasource.py @@ -25,24 +25,31 @@ def main(): - parser = argparse.ArgumentParser(description='Publish a datasource to server.') + parser = argparse.ArgumentParser(description="Publish a datasource to server.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - 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)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, 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 - parser.add_argument('--file', '-f', required=True, help='filepath to the datasource to publish') - parser.add_argument('--project', help='Project within which to publish the datasource') - parser.add_argument('--async', '-a', help='Publishing asynchronously', dest='async_', action='store_true') - parser.add_argument('--conn-username', help='connection username') - parser.add_argument('--conn-password', help='connection password') - parser.add_argument('--conn-embed', help='embed connection password to datasource', action='store_true') - parser.add_argument('--conn-oauth', help='connection is configured to use oAuth', action='store_true') + parser.add_argument("--file", "-f", required=True, help="filepath to the datasource to publish") + parser.add_argument("--project", help="Project within which to publish the datasource") + parser.add_argument("--async", "-a", help="Publishing asynchronously", dest="async_", action="store_true") + parser.add_argument("--conn-username", help="connection username") + parser.add_argument("--conn-password", help="connection password") + parser.add_argument("--conn-embed", help="embed connection password to datasource", action="store_true") + parser.add_argument("--conn-oauth", help="connection is configured to use oAuth", action="store_true") args = parser.parse_args() @@ -64,9 +71,9 @@ def main(): # Retrieve the project id, if a project name was passed if args.project is not None: req_options = TSC.RequestOptions() - req_options.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Operator.Equals, - args.project)) + req_options.filter.add( + TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, args.project) + ) projects = list(TSC.Pager(server.projects, req_options)) if len(projects) > 1: raise ValueError("The project name is not unique") @@ -78,8 +85,9 @@ def main(): # Create a connection_credentials item if connection details are provided new_conn_creds = None if args.conn_username: - new_conn_creds = TSC.ConnectionCredentials(args.conn_username, args.conn_password, - embed=args.conn_embed, oauth=args.conn_oauth) + new_conn_creds = TSC.ConnectionCredentials( + args.conn_username, args.conn_password, embed=args.conn_embed, oauth=args.conn_oauth + ) # Define publish mode - Overwrite, Append, or CreateNew publish_mode = TSC.Server.PublishMode.Overwrite @@ -87,15 +95,17 @@ def main(): # Publish datasource if args.async_: # Async publishing, returns a job_item - new_job = server.datasources.publish(new_datasource, args.filepath, publish_mode, - connection_credentials=new_conn_creds, as_job=True) + new_job = server.datasources.publish( + new_datasource, args.filepath, publish_mode, connection_credentials=new_conn_creds, as_job=True + ) print("Datasource published asynchronously. Job ID: {0}".format(new_job.id)) else: # Normal publishing, returns a datasource_item - new_datasource = server.datasources.publish(new_datasource, args.filepath, publish_mode, - connection_credentials=new_conn_creds) + new_datasource = server.datasources.publish( + new_datasource, args.filepath, publish_mode, connection_credentials=new_conn_creds + ) print("Datasource published. Datasource ID: {0}".format(new_datasource.id)) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index fcfcddc15..c553eda0b 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -23,21 +23,27 @@ def main(): - parser = argparse.ArgumentParser(description='Publish a workbook to server.') + parser = argparse.ArgumentParser(description="Publish a workbook to server.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - 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)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, 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 - parser.add_argument('--file', '-f', required=True, help='local filepath of the workbook to publish') - parser.add_argument('--as-job', '-a', help='Publishing asynchronously', action='store_true') - parser.add_argument('--skip-connection-check', '-c', help='Skip live connection check', action='store_true') - + parser.add_argument("--file", "-f", required=True, help="local filepath of the workbook to publish") + parser.add_argument("--as-job", "-a", help="Publishing asynchronously", action="store_true") + parser.add_argument("--skip-connection-check", "-c", help="Skip live connection check", action="store_true") args = parser.parse_args() @@ -72,19 +78,29 @@ def main(): if default_project is not None: new_workbook = TSC.WorkbookItem(default_project.id) if args.as_job: - new_job = server.workbooks.publish(new_workbook, args.filepath, overwrite_true, - connections=all_connections, as_job=args.as_job, - skip_connection_check=args.skip_connection_check) + new_job = server.workbooks.publish( + new_workbook, + args.filepath, + overwrite_true, + connections=all_connections, + as_job=args.as_job, + skip_connection_check=args.skip_connection_check, + ) print("Workbook published. JOB ID: {0}".format(new_job.id)) else: - new_workbook = server.workbooks.publish(new_workbook, args.filepath, overwrite_true, - connections=all_connections, as_job=args.as_job, - skip_connection_check=args.skip_connection_check) + new_workbook = server.workbooks.publish( + new_workbook, + args.filepath, + overwrite_true, + connections=all_connections, + as_job=args.as_job, + skip_connection_check=args.skip_connection_check, + ) print("Workbook published. ID: {0}".format(new_workbook.id)) else: error = "The default project could not be found." raise LookupError(error) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/query_permissions.py b/samples/query_permissions.py index 0909f915d..c0d1c3afa 100644 --- a/samples/query_permissions.py +++ b/samples/query_permissions.py @@ -13,19 +13,26 @@ def main(): - parser = argparse.ArgumentParser(description='Query permissions of a given resource.') + parser = argparse.ArgumentParser(description="Query permissions of a given resource.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - 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)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, 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 - parser.add_argument('resource_type', choices=['workbook', 'datasource', 'flow', 'table', 'database']) - parser.add_argument('resource_id') + parser.add_argument("resource_type", choices=["workbook", "datasource", "flow", "table", "database"]) + parser.add_argument("resource_id") args = parser.parse_args() @@ -40,11 +47,11 @@ def main(): # Mapping to grab the handler for the user-inputted resource type endpoint = { - 'workbook': server.workbooks, - 'datasource': server.datasources, - 'flow': server.flows, - 'table': server.tables, - 'database': server.databases + "workbook": server.workbooks, + "datasource": server.datasources, + "flow": server.flows, + "table": server.tables, + "database": server.databases, }.get(args.resource_type) # Get the resource by its ID @@ -55,8 +62,9 @@ def main(): permissions = resource.permissions # Print result - print("\n{0} permission rule(s) found for {1} {2}." - .format(len(permissions), args.resource_type, args.resource_id)) + print( + "\n{0} permission rule(s) found for {1} {2}.".format(len(permissions), args.resource_type, args.resource_id) + ) for permission in permissions: grantee = permission.grantee @@ -67,5 +75,5 @@ def main(): print("\t{0} - {1}".format(capability, capabilities[capability])) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/refresh.py b/samples/refresh.py index 3eed5b4be..18a7f36e2 100644 --- a/samples/refresh.py +++ b/samples/refresh.py @@ -11,19 +11,26 @@ def main(): - parser = argparse.ArgumentParser(description='Trigger a refresh task on a workbook or datasource.') + parser = argparse.ArgumentParser(description="Trigger a refresh task on a workbook or datasource.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - 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)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, 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 - parser.add_argument('resource_type', choices=['workbook', 'datasource']) - parser.add_argument('resource_id') + parser.add_argument("resource_type", choices=["workbook", "datasource"]) + parser.add_argument("resource_id") args = parser.parse_args() @@ -46,7 +53,7 @@ def main(): # trigger the refresh, you'll get a job id back which can be used to poll for when the refresh is done job = server.datasources.refresh(resource) - + print(f"Update job posted (ID: {job.id})") print("Waiting for job...") # `wait_for_job` will throw if the job isn't executed successfully @@ -54,5 +61,5 @@ def main(): print("Job finished succesfully") -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/refresh_tasks.py b/samples/refresh_tasks.py index bf69d064a..6ef781544 100644 --- a/samples/refresh_tasks.py +++ b/samples/refresh_tasks.py @@ -28,28 +28,35 @@ def handle_info(server, args): def main(): - parser = argparse.ArgumentParser(description='Get all of the refresh tasks available on a server') + parser = argparse.ArgumentParser(description="Get all of the refresh tasks available on a server") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - 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)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, 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 subcommands = parser.add_subparsers() - list_arguments = subcommands.add_parser('list') + list_arguments = subcommands.add_parser("list") list_arguments.set_defaults(func=handle_list) - run_arguments = subcommands.add_parser('run') - run_arguments.add_argument('id', default=None) + run_arguments = subcommands.add_parser("run") + run_arguments.add_argument("id", default=None) run_arguments.set_defaults(func=handle_run) - info_arguments = subcommands.add_parser('info') - info_arguments.add_argument('id', default=None) + info_arguments = subcommands.add_parser("info") + info_arguments.add_argument("id", default=None) info_arguments.set_defaults(func=handle_info) args = parser.parse_args() @@ -65,5 +72,5 @@ def main(): args.func(server, args) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/set_http_options.py b/samples/set_http_options.py deleted file mode 100644 index 40ed9167e..000000000 --- a/samples/set_http_options.py +++ /dev/null @@ -1,52 +0,0 @@ -#### -# This script demonstrates how to set http options. It will set the option -# to not verify SSL certificate, and query all workbooks on site. -# -# To run the script, you must have installed Python 3.6 or later. -#### - -import argparse -import logging - -import tableauserverclient as TSC - - -def main(): - - parser = argparse.ArgumentParser(description='List workbooks on site, with option set to ignore SSL verification.') - # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - 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) - - # Step 1: Create required objects for sign in - tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) - server = TSC.Server(args.server) - - # Step 2: Set http options to disable verifying SSL - server.add_http_options({'verify': False}) - - with server.auth.sign_in(tableau_auth): - - # Step 3: Query all workbooks and list them - all_workbooks, pagination_item = server.workbooks.get() - print('{0} workbooks found. Showing {1}:'.format(pagination_item.total_available, pagination_item.page_size)) - for workbook in all_workbooks: - print('\t{0} (ID: {1})'.format(workbook.name, workbook.id)) - - -if __name__ == '__main__': - main() diff --git a/samples/set_refresh_schedule.py b/samples/set_refresh_schedule.py index 862ea2372..decdc223f 100644 --- a/samples/set_refresh_schedule.py +++ b/samples/set_refresh_schedule.py @@ -13,21 +13,29 @@ def usage(args): - parser = argparse.ArgumentParser(description='Set refresh schedule for a workbook or datasource.') + parser = argparse.ArgumentParser(description="Set refresh schedule for a workbook or datasource.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - 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)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, 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 group = parser.add_mutually_exclusive_group(required=True) - group.add_argument('--workbook', '-w') - group.add_argument('--datasource', '-d') - parser.add_argument('schedule') + group.add_argument("--workbook", "-w") + group.add_argument("--datasource", "-d") + group.add_argument("--flow", "-f") + parser.add_argument("schedule") return parser.parse_args(args) @@ -54,6 +62,13 @@ def get_datasource_by_name(server, name): return datasources.pop() +def get_flow_by_name(server, name): + request_filter = make_filter(Name=name) + flows, _ = server.flows.get(request_filter) + assert len(flows) == 1 + return flows.pop() + + def get_schedule_by_name(server, name): schedules = [x for x in TSC.Pager(server.schedules) if x.name == name] assert len(schedules) == 1 @@ -75,8 +90,13 @@ def run(args): with server.auth.sign_in(tableau_auth): if args.workbook: item = get_workbook_by_name(server, args.workbook) - else: + elif args.datasource: item = get_datasource_by_name(server, args.datasource) + elif args.flow: + item = get_flow_by_name(server, args.flow) + else: + print("A scheduleable item must be included") + return schedule = get_schedule_by_name(server, args.schedule) assign_to_schedule(server, item, schedule) @@ -84,6 +104,7 @@ def run(args): def main(): import sys + args = usage(sys.argv[1:]) run(args) diff --git a/samples/update_connection.py b/samples/update_connection.py index 0e87217e8..44f8ec6c0 100644 --- a/samples/update_connection.py +++ b/samples/update_connection.py @@ -11,22 +11,29 @@ def main(): - parser = argparse.ArgumentParser(description='Update a connection on a datasource or workbook to embed credentials') + parser = argparse.ArgumentParser(description="Update a connection on a datasource or workbook to embed credentials") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - 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)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, 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 - parser.add_argument('resource_type', choices=['workbook', 'datasource']) - parser.add_argument('resource_id') - parser.add_argument('connection_id') - parser.add_argument('datasource_username') - parser.add_argument('datasource_password') + parser.add_argument("resource_type", choices=["workbook", "datasource"]) + parser.add_argument("resource_id") + parser.add_argument("connection_id") + parser.add_argument("datasource_username") + parser.add_argument("datasource_password") args = parser.parse_args() @@ -37,16 +44,13 @@ def main(): tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - endpoint = { - 'workbook': server.workbooks, - 'datasource': server.datasources - }.get(args.resource_type) + endpoint = {"workbook": server.workbooks, "datasource": server.datasources}.get(args.resource_type) update_function = endpoint.update_connection resource = endpoint.get_by_id(args.resource_id) endpoint.populate_connections(resource) connections = list(filter(lambda x: x.id == args.connection_id, resource.connections)) - assert(len(connections) == 1) + assert len(connections) == 1 connection = connections[0] connection.username = args.datasource_username connection.password = args.datasource_password @@ -54,5 +58,5 @@ def main(): print(update_function(resource, connection).__dict__) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/update_datasource_data.py b/samples/update_datasource_data.py index 74c8ea6fb..41f42ee74 100644 --- a/samples/update_datasource_data.py +++ b/samples/update_datasource_data.py @@ -21,18 +21,27 @@ def main(): - parser = argparse.ArgumentParser(description='Delete the `Europe` region from a published `World Indicators` datasource.') + parser = argparse.ArgumentParser( + description="Delete the `Europe` region from a published `World Indicators` datasource." + ) # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - 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)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, 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 - parser.add_argument('datasource_id', help="The LUID of the `World Indicators` datasource") + parser.add_argument("datasource_id", help="The LUID of the `World Indicators` datasource") args = parser.parse_args() @@ -61,7 +70,7 @@ def main(): "action": "delete", "target-table": "Extract", "target-schema": "Extract", - "condition": {"op": "eq", "target-col": "Region", "const": {"type": "string", "v": "Europe"}} + "condition": {"op": "eq", "target-col": "Region", "const": {"type": "string", "v": "Europe"}}, } ] @@ -74,5 +83,5 @@ def main(): print("Job finished succesfully") -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/setup.cfg b/setup.cfg index 6136b814a..dafb578b7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,10 +1,6 @@ [wheel] universal = 1 -[pycodestyle] -select = -max_line_length = 120 - [pep8] max_line_length = 120 @@ -14,7 +10,7 @@ max_line_length = 120 [versioneer] VCS = git -style = pep440 +style = pep440-pre versionfile_source = tableauserverclient/_version.py versionfile_build = tableauserverclient/_version.py tag_prefix = v @@ -26,3 +22,6 @@ smoke=pytest [tool:pytest] testpaths = test smoke addopts = --junitxml=./test.junit.xml + +[mypy] +ignore_missing_imports = True diff --git a/setup.py b/setup.py index 8b374f0ce..ae19dcd26 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ # This makes work easier for offline installs or low bandwidth machines needs_pytest = {'pytest', 'test', 'ptr'}.intersection(sys.argv) pytest_runner = ['pytest-runner'] if needs_pytest else [] -test_requirements = ['mock', 'pycodestyle', 'pytest', 'requests-mock>=1.0,<2.0'] +test_requirements = ['black', 'mock', 'pytest', 'requests-mock>=1.0,<2.0', 'mypy==0.910'] setup( name='tableauserverclient', @@ -24,6 +24,7 @@ author='Tableau', author_email='github@tableau.com', url='https://github.com/tableau/server-client-python', + package_data={'tableauserverclient':['py.typed']}, packages=['tableauserverclient', 'tableauserverclient.models', 'tableauserverclient.server', 'tableauserverclient.server.endpoint'], license='MIT', @@ -33,10 +34,12 @@ test_suite='test', setup_requires=pytest_runner, install_requires=[ + 'defusedxml>=0.7.1', 'requests>=2.11,<3.0', ], tests_require=test_requirements, extras_require={ 'test': test_requirements - } + }, + zip_safe=False ) diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 2ad65d71e..897c69fb0 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -1,4 +1,4 @@ -from .namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE +from ._version import get_versions from .models import ( ConnectionCredentials, ConnectionItem, @@ -34,8 +34,11 @@ FlowItem, WebhookItem, PersonalAccessTokenAuth, - FlowRunItem + FlowRunItem, + RevisionItem, + MetricItem, ) +from .namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE from .server import ( RequestOptions, CSVRequestOptions, @@ -49,7 +52,6 @@ NotSignedInError, Pager, ) -from ._version import get_versions __version__ = get_versions()["version"] __VERSION__ = __version__ diff --git a/tableauserverclient/_version.py b/tableauserverclient/_version.py index 1737a980a..d47374097 100644 --- a/tableauserverclient/_version.py +++ b/tableauserverclient/_version.py @@ -51,7 +51,7 @@ class NotThisMethod(Exception): """Exception raised if a method is not valid for the current scenario.""" -LONG_VERSION_PY = {} +LONG_VERSION_PY = {} # type: ignore HANDLERS = {} @@ -120,7 +120,7 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): dirname = os.path.basename(root) if dirname.startswith(parentdir_prefix): return { - "version": dirname[len(parentdir_prefix):], + "version": dirname[len(parentdir_prefix) :], "full-revisionid": None, "dirty": False, "error": None, @@ -187,7 +187,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. TAG = "tag: " - tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) + tags = set([r[len(TAG) :] for r in refs if r.startswith(TAG)]) if not tags: # Either we're using git < 1.8.3, or there really are no tags. We use # a heuristic: assume all version tags have a digit. The old git %d @@ -204,7 +204,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): for ref in sorted(tags): # sorting will prefer e.g. "2.0" over "2.0rc1" if ref.startswith(tag_prefix): - r = ref[len(tag_prefix):] + r = ref[len(tag_prefix) :] if verbose: print("picking %s" % r) return { @@ -304,7 +304,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): tag_prefix, ) return pieces - pieces["closest-tag"] = full_tag[len(tag_prefix):] + pieces["closest-tag"] = full_tag[len(tag_prefix) :] # distance: number of commits since tag pieces["distance"] = int(mo.group(2)) diff --git a/tableauserverclient/exponential_backoff.py b/tableauserverclient/exponential_backoff.py index 2b3ded109..69ffdc96b 100644 --- a/tableauserverclient/exponential_backoff.py +++ b/tableauserverclient/exponential_backoff.py @@ -1,12 +1,12 @@ import time # Polling for server-side events (such as job completion) uses exponential backoff for the sleep intervals between polls -ASYNC_POLL_MIN_INTERVAL=0.5 -ASYNC_POLL_MAX_INTERVAL=30 -ASYNC_POLL_BACKOFF_FACTOR=1.4 +ASYNC_POLL_MIN_INTERVAL = 0.5 +ASYNC_POLL_MAX_INTERVAL = 30 +ASYNC_POLL_BACKOFF_FACTOR = 1.4 -class ExponentialBackoffTimer(): +class ExponentialBackoffTimer: def __init__(self, *, timeout=None): self.start_time = time.time() self.timeout = timeout @@ -15,7 +15,7 @@ def __init__(self, *, timeout=None): def sleep(self): max_sleep_time = ASYNC_POLL_MAX_INTERVAL if self.timeout is not None: - elapsed = (time.time() - self.start_time) + elapsed = time.time() - self.start_time if elapsed >= self.timeout: raise TimeoutError(f"Timeout after {elapsed} seconds waiting for asynchronous event") remaining_time = self.timeout - elapsed @@ -27,4 +27,4 @@ def sleep(self): max_sleep_time = max(max_sleep_time, ASYNC_POLL_MIN_INTERVAL) time.sleep(min(self.current_sleep_interval, max_sleep_time)) - self.current_sleep_interval *= ASYNC_POLL_BACKOFF_FACTOR \ No newline at end of file + self.current_sleep_interval *= ASYNC_POLL_BACKOFF_FACTOR diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index e5945782d..f72878366 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -1,16 +1,16 @@ +from .column_item import ColumnItem from .connection_credentials import ConnectionCredentials from .connection_item import ConnectionItem -from .column_item import ColumnItem from .data_acceleration_report_item import DataAccelerationReportItem from .data_alert_item import DataAlertItem -from .datasource_item import DatasourceItem from .database_item import DatabaseItem +from .datasource_item import DatasourceItem from .dqw_item import DQWItem from .exceptions import UnpopulatedPropertyError from .favorites_item import FavoriteItem -from .group_item import GroupItem from .flow_item import FlowItem from .flow_run_item import FlowRunItem +from .group_item import GroupItem from .interval_item import ( IntervalItem, DailyInterval, @@ -19,20 +19,22 @@ HourlyInterval, ) from .job_item import JobItem, BackgroundJobItem +from .metric_item import MetricItem from .pagination_item import PaginationItem +from .permissions_item import PermissionsRule, Permission +from .personal_access_token_auth import PersonalAccessTokenAuth +from .personal_access_token_auth import PersonalAccessTokenAuth 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 TableauAuth -from .personal_access_token_auth import PersonalAccessTokenAuth from .target import Target -from .table_item import TableItem from .task_item import TaskItem from .user_item import UserItem from .view_item import ViewItem -from .workbook_item import WorkbookItem -from .subscription_item import SubscriptionItem -from .permissions_item import PermissionsRule, Permission from .webhook_item import WebhookItem -from .personal_access_token_auth import PersonalAccessTokenAuth +from .workbook_item import WorkbookItem diff --git a/tableauserverclient/models/column_item.py b/tableauserverclient/models/column_item.py index a95d005ca..dbf200d21 100644 --- a/tableauserverclient/models/column_item.py +++ b/tableauserverclient/models/column_item.py @@ -1,4 +1,4 @@ -import xml.etree.ElementTree as ET +from defusedxml.ElementTree import fromstring from .property_decorators import property_not_empty @@ -47,7 +47,7 @@ def _set_values(self, id, name, description, remote_type): @classmethod def from_response(cls, resp, ns): all_column_items = list() - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) all_column_xml = parsed_response.findall(".//t:column", namespaces=ns) for column_xml in all_column_xml: diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 018c093c7..17ca20bb9 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -1,4 +1,5 @@ -import xml.etree.ElementTree as ET +from defusedxml.ElementTree import fromstring + from .connection_credentials import ConnectionCredentials @@ -39,7 +40,7 @@ def __repr__(self): @classmethod def from_response(cls, resp, ns): all_connection_items = list() - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) all_connection_xml = parsed_response.findall(".//t:connection", namespaces=ns) for connection_xml in all_connection_xml: connection_item = cls() diff --git a/tableauserverclient/models/data_acceleration_report_item.py b/tableauserverclient/models/data_acceleration_report_item.py index eab6c24cd..3c1d6ed40 100644 --- a/tableauserverclient/models/data_acceleration_report_item.py +++ b/tableauserverclient/models/data_acceleration_report_item.py @@ -1,4 +1,4 @@ -import xml.etree.ElementTree as ET +from defusedxml.ElementTree import fromstring class DataAccelerationReportItem(object): @@ -70,7 +70,7 @@ def _parse_element(comparison_record_xml, ns): @classmethod def from_response(cls, resp, ns): comparison_records = list() - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) all_comparison_records_xml = parsed_response.findall(".//t:comparisonRecord", namespaces=ns) for comparison_record_xml in all_comparison_records_xml: ( diff --git a/tableauserverclient/models/data_alert_item.py b/tableauserverclient/models/data_alert_item.py index a4d11ca5e..1455743cd 100644 --- a/tableauserverclient/models/data_alert_item.py +++ b/tableauserverclient/models/data_alert_item.py @@ -1,12 +1,15 @@ -import xml.etree.ElementTree as ET +from typing import List, Optional, TYPE_CHECKING + +from defusedxml.ElementTree import fromstring from .property_decorators import ( property_not_empty, property_is_enum, property_is_boolean, ) -from .user_item import UserItem -from .view_item import ViewItem + +if TYPE_CHECKING: + from datetime import datetime class DataAlertItem(object): @@ -18,35 +21,35 @@ class Frequency: Weekly = "Weekly" def __init__(self): - self._id = None - self._subject = None - self._creatorId = None - self._createdAt = None - self._updatedAt = None - self._frequency = None - self._public = None - self._owner_id = None - self._owner_name = None - self._view_id = None - self._view_name = None - self._workbook_id = None - self._workbook_name = None - self._project_id = None - self._project_name = None - self._recipients = None - - def __repr__(self): + self._id: Optional[str] = None + self._subject: Optional[str] = None + self._creatorId: Optional[str] = None + self._createdAt: Optional["datetime"] = None + self._updatedAt: Optional["datetime"] = None + self._frequency: Optional[str] = None + self._public: Optional[bool] = None + self._owner_id: Optional[str] = None + self._owner_name: Optional[str] = None + self._view_id: Optional[str] = None + self._view_name: Optional[str] = None + self._workbook_id: Optional[str] = None + self._workbook_name: Optional[str] = None + self._project_id: Optional[str] = None + self._project_name: Optional[str] = None + self._recipients: Optional[List[str]] = None + + def __repr__(self) -> str: return "".format( **self.__dict__ ) @property - def id(self): + def id(self) -> Optional[str]: return self._id @property - def subject(self): + def subject(self) -> Optional[str]: return self._subject @subject.setter @@ -55,69 +58,69 @@ def subject(self, value): self._subject = value @property - def frequency(self): + def frequency(self) -> Optional[str]: return self._frequency @frequency.setter @property_is_enum(Frequency) - def frequency(self, value): + def frequency(self, value: str) -> None: self._frequency = value @property - def public(self): + def public(self) -> Optional[bool]: return self._public @public.setter @property_is_boolean - def public(self, value): + def public(self, value: bool) -> None: self._public = value @property - def creatorId(self): + def creatorId(self) -> Optional[str]: return self._creatorId @property - def recipients(self): + def recipients(self) -> List[str]: return self._recipients or list() @property - def createdAt(self): + def createdAt(self) -> Optional["datetime"]: return self._createdAt @property - def updatedAt(self): + def updatedAt(self) -> Optional["datetime"]: return self._updatedAt @property - def owner_id(self): + def owner_id(self) -> Optional[str]: return self._owner_id @property - def owner_name(self): + def owner_name(self) -> Optional[str]: return self._owner_name @property - def view_id(self): + def view_id(self) -> Optional[str]: return self._view_id @property - def view_name(self): + def view_name(self) -> Optional[str]: return self._view_name @property - def workbook_id(self): + def workbook_id(self) -> Optional[str]: return self._workbook_id @property - def workbook_name(self): + def workbook_name(self) -> Optional[str]: return self._workbook_name @property - def project_id(self): + def project_id(self) -> Optional[str]: return self._project_id @property - def project_name(self): + def project_name(self) -> Optional[str]: return self._project_name def _set_values( @@ -173,9 +176,9 @@ def _set_values( self._recipients = recipients @classmethod - def from_response(cls, resp, ns): + def from_response(cls, resp, ns) -> List["DataAlertItem"]: all_alert_items = list() - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) all_alert_xml = parsed_response.findall(".//t:dataAlert", namespaces=ns) for alert_xml in all_alert_xml: diff --git a/tableauserverclient/models/database_item.py b/tableauserverclient/models/database_item.py index 4934af81b..862a51a11 100644 --- a/tableauserverclient/models/database_item.py +++ b/tableauserverclient/models/database_item.py @@ -1,11 +1,11 @@ -import xml.etree.ElementTree as ET +from defusedxml.ElementTree import fromstring +from .exceptions import UnpopulatedPropertyError from .property_decorators import ( property_is_enum, property_not_empty, property_is_boolean, ) -from .exceptions import UnpopulatedPropertyError class DatabaseItem(object): @@ -53,6 +53,11 @@ def dqws(self): def content_permissions(self): return self._content_permissions + @content_permissions.setter + @property_is_enum(ContentPermissions) + def content_permissions(self, value): + self._content_permissions = value + @property def permissions(self): if self._permissions is None: @@ -67,11 +72,6 @@ def default_table_permissions(self): raise UnpopulatedPropertyError(error) return self._default_table_permissions() - @content_permissions.setter - @property_is_enum(ContentPermissions) - def content_permissions(self, value): - self._content_permissions = value - @property def id(self): return self._id @@ -254,7 +254,7 @@ def _set_data_quality_warnings(self, dqw): @classmethod def from_response(cls, resp, ns): all_database_items = list() - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) all_database_xml = parsed_response.findall(".//t:database", namespaces=ns) for database_xml in all_database_xml: diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 5b23341d0..c7823918f 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -1,4 +1,9 @@ +import copy import xml.etree.ElementTree as ET +from typing import Dict, List, Optional, Set, Tuple, TYPE_CHECKING + +from defusedxml.ElementTree import fromstring + from .exceptions import UnpopulatedPropertyError from .property_decorators import ( property_not_nullable, @@ -7,7 +12,19 @@ ) from .tag_item import TagItem from ..datetime_helpers import parse_datetime -import copy + +if TYPE_CHECKING: + from .permissions_item import PermissionsRule + from .connection_item import ConnectionItem + from .revision_item import RevisionItem + import datetime + +from typing import Dict, List, Optional, Set, Tuple, TYPE_CHECKING, Union + +if TYPE_CHECKING: + from .permissions_item import PermissionsRule + from .connection_item import ConnectionItem + import datetime class DatasourceItem(object): @@ -16,79 +33,82 @@ class AskDataEnablement: Disabled = "Disabled" SiteDefault = "SiteDefault" - def __init__(self, project_id, name=None): + def __init__(self, project_id: str, name: str = None) -> None: self._ask_data_enablement = None self._certified = None self._certification_note = None self._connections = None - self._content_url = None + self._content_url: Optional[str] = None self._created_at = None self._datasource_type = None self._description = None self._encrypt_extracts = None self._has_extracts = None - self._id = None - self._initial_tags = set() - self._project_name = None + self._id: Optional[str] = None + self._initial_tags: Set = set() + self._project_name: Optional[str] = None + self._revisions = None self._updated_at = None self._use_remote_query_agent = None self._webpage_url = None self.description = None self.name = name - self.owner_id = None + self.owner_id: Optional[str] = None self.project_id = project_id - self.tags = set() + self.tags: Set[str] = set() self._permissions = None self._data_quality_warnings = None + return None + @property - def ask_data_enablement(self): + def ask_data_enablement(self) -> Optional["DatasourceItem.AskDataEnablement"]: return self._ask_data_enablement @ask_data_enablement.setter @property_is_enum(AskDataEnablement) - def ask_data_enablement(self, value): + def ask_data_enablement(self, value: Optional["DatasourceItem.AskDataEnablement"]): self._ask_data_enablement = value @property - def connections(self): + def connections(self) -> Optional[List["ConnectionItem"]]: if self._connections is None: error = "Datasource item must be populated with connections first." raise UnpopulatedPropertyError(error) return self._connections() @property - def permissions(self): + def permissions(self) -> Optional[List["PermissionsRule"]]: if self._permissions is None: error = "Project item must be populated with permissions first." raise UnpopulatedPropertyError(error) return self._permissions() @property - def content_url(self): + def content_url(self) -> Optional[str]: return self._content_url @property - def created_at(self): + def created_at(self) -> Optional["datetime.datetime"]: return self._created_at @property - def certified(self): + def certified(self) -> Optional[bool]: return self._certified @certified.setter @property_not_nullable @property_is_boolean - def certified(self, value): + def certified(self, value: Optional[bool]): self._certified = value @property - def certification_note(self): + def certification_note(self) -> Optional[str]: return self._certification_note @certification_note.setter - def certification_note(self, value): + def certification_note(self, value: Optional[str]): self._certification_note = value @property @@ -97,7 +117,7 @@ def encrypt_extracts(self): @encrypt_extracts.setter @property_is_boolean - def encrypt_extracts(self, value): + def encrypt_extracts(self, value: Optional[bool]): self._encrypt_extracts = value @property @@ -108,55 +128,62 @@ def dqws(self): return self._data_quality_warnings() @property - def has_extracts(self): + def has_extracts(self) -> Optional[bool]: return self._has_extracts @property - def id(self): + def id(self) -> Optional[str]: return self._id @property - def project_id(self): + def project_id(self) -> str: return self._project_id @project_id.setter @property_not_nullable - def project_id(self, value): + def project_id(self, value: str): self._project_id = value @property - def project_name(self): + def project_name(self) -> Optional[str]: return self._project_name @property - def datasource_type(self): + def datasource_type(self) -> Optional[str]: return self._datasource_type @property - def description(self): + def description(self) -> Optional[str]: return self._description @description.setter - def description(self, value): + def description(self, value: str): self._description = value @property - def updated_at(self): + def updated_at(self) -> Optional["datetime.datetime"]: return self._updated_at @property - def use_remote_query_agent(self): + def use_remote_query_agent(self) -> Optional[bool]: return self._use_remote_query_agent @use_remote_query_agent.setter @property_is_boolean - def use_remote_query_agent(self, value): + def use_remote_query_agent(self, value: bool): self._use_remote_query_agent = value @property - def webpage_url(self): + def webpage_url(self) -> Optional[str]: return self._webpage_url + @property + def revisions(self) -> List["RevisionItem"]: + if self._revisions is None: + error = "Datasource item must be populated with revisions first." + raise UnpopulatedPropertyError(error) + return self._revisions() + def _set_connections(self, connections): self._connections = connections @@ -166,9 +193,12 @@ def _set_permissions(self, permissions): def _set_data_quality_warnings(self, dqws): self._data_quality_warnings = dqws + def _set_revisions(self, revisions): + self._revisions = revisions + def _parse_common_elements(self, datasource_xml, ns): if not isinstance(datasource_xml, ET.Element): - datasource_xml = ET.fromstring(datasource_xml).find(".//t:datasource", namespaces=ns) + datasource_xml = fromstring(datasource_xml).find(".//t:datasource", namespaces=ns) if datasource_xml is not None: ( ask_data_enablement, @@ -271,9 +301,9 @@ def _set_values( self._webpage_url = webpage_url @classmethod - def from_response(cls, resp, ns): + def from_response(cls, resp: str, ns: Dict) -> List["DatasourceItem"]: all_datasource_items = list() - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) all_datasource_xml = parsed_response.findall(".//t:datasource", namespaces=ns) for datasource_xml in all_datasource_xml: @@ -322,16 +352,16 @@ def from_response(cls, resp, ns): return all_datasource_items @staticmethod - def _parse_element(datasource_xml, ns): - id_ = datasource_xml.get('id', None) - name = datasource_xml.get('name', None) - datasource_type = datasource_xml.get('type', None) - description = datasource_xml.get('description', None) - content_url = datasource_xml.get('contentUrl', None) - created_at = parse_datetime(datasource_xml.get('createdAt', None)) - 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' + def _parse_element(datasource_xml: ET.Element, ns: Dict) -> Tuple: + id_ = datasource_xml.get("id", None) + name = datasource_xml.get("name", None) + datasource_type = datasource_xml.get("type", None) + description = datasource_xml.get("description", None) + content_url = datasource_xml.get("contentUrl", None) + created_at = parse_datetime(datasource_xml.get("createdAt", None)) + 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) diff --git a/tableauserverclient/models/dqw_item.py b/tableauserverclient/models/dqw_item.py index a7f8ec9cb..2baecee09 100644 --- a/tableauserverclient/models/dqw_item.py +++ b/tableauserverclient/models/dqw_item.py @@ -1,4 +1,5 @@ -import xml.etree.ElementTree as ET +from defusedxml.ElementTree import fromstring + from ..datetime_helpers import parse_datetime @@ -80,14 +81,6 @@ def severe(self): def severe(self, value): self._severe = value - @property - def active(self): - return self._active - - @active.setter - def active(self, value): - self._active = value - @property def created_at(self): return self._created_at @@ -106,7 +99,7 @@ def updated_at(self, value): @classmethod def from_response(cls, resp, ns): - return cls.from_xml_element(ET.fromstring(resp), ns) + return cls.from_xml_element(fromstring(resp), ns) @classmethod def from_xml_element(cls, parsed_response, ns): diff --git a/tableauserverclient/models/favorites_item.py b/tableauserverclient/models/favorites_item.py index 3d6feff5d..afa769fd9 100644 --- a/tableauserverclient/models/favorites_item.py +++ b/tableauserverclient/models/favorites_item.py @@ -1,30 +1,50 @@ -import xml.etree.ElementTree as ET import logging -from .workbook_item import WorkbookItem -from .view_item import ViewItem -from .project_item import ProjectItem + +from defusedxml.ElementTree import fromstring + from .datasource_item import DatasourceItem +from .flow_item import FlowItem +from .project_item import ProjectItem +from .view_item import ViewItem +from .workbook_item import WorkbookItem logger = logging.getLogger("tableau.models.favorites_item") +from typing import Dict, List, Union + +FavoriteType = Dict[ + str, + List[ + Union[ + DatasourceItem, + ProjectItem, + FlowItem, + ViewItem, + WorkbookItem, + ] + ], +] + class FavoriteItem: class Type: - Workbook = "workbook" - Datasource = "datasource" - View = "view" - Project = "project" + Workbook: str = "workbook" + Datasource: str = "datasource" + View: str = "view" + Project: str = "project" + Flow: str = "flow" @classmethod - def from_response(cls, xml, namespace): - favorites = { + def from_response(cls, xml: str, namespace: Dict) -> FavoriteType: + favorites: FavoriteType = { "datasources": [], + "flows": [], "projects": [], "views": [], "workbooks": [], } - parsed_response = ET.fromstring(xml) + parsed_response = fromstring(xml) for workbook in parsed_response.findall(".//t:favorite/t:workbook", namespace): fav_workbook = WorkbookItem("") fav_workbook._set_values(*fav_workbook._parse_element(workbook, namespace)) @@ -45,5 +65,10 @@ def from_response(cls, xml, namespace): fav_project._set_values(*fav_project._parse_element(project)) if fav_project: favorites["projects"].append(fav_project) + for flow in parsed_response.findall(".//t:favorite/t:flow", namespace): + fav_flow = FlowItem("flows") + fav_flow._set_values(*fav_flow._parse_element(flow, namespace)) + if fav_flow: + favorites["flows"].append(fav_flow) return favorites diff --git a/tableauserverclient/models/fileupload_item.py b/tableauserverclient/models/fileupload_item.py index a697a5aaf..7848b94cf 100644 --- a/tableauserverclient/models/fileupload_item.py +++ b/tableauserverclient/models/fileupload_item.py @@ -1,4 +1,4 @@ -import xml.etree.ElementTree as ET +from defusedxml.ElementTree import fromstring class FileuploadItem(object): @@ -16,7 +16,7 @@ def file_size(self): @classmethod def from_response(cls, resp, ns): - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) fileupload_elem = parsed_response.find(".//t:fileUpload", namespaces=ns) fileupload_item = cls() fileupload_item._upload_session_id = fileupload_elem.get("uploadSessionId", None) diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index d1387f368..96a99c943 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -1,24 +1,31 @@ +import copy import xml.etree.ElementTree as ET +from typing import List, Optional, TYPE_CHECKING, Set + +from defusedxml.ElementTree import fromstring + from .exceptions import UnpopulatedPropertyError from .property_decorators import property_not_nullable from .tag_item import TagItem from ..datetime_helpers import parse_datetime -import copy + +if TYPE_CHECKING: + import datetime class FlowItem(object): - def __init__(self, project_id, name=None): - self._webpage_url = None - self._created_at = None - self._id = None - self._initial_tags = set() - self._project_name = None - self._updated_at = None - self.name = name - self.owner_id = None - self.project_id = project_id - self.tags = set() - self.description = None + def __init__(self, project_id: str, name: Optional[str] = None) -> None: + self._webpage_url: Optional[str] = None + self._created_at: Optional["datetime.datetime"] = None + self._id: Optional[str] = None + self._initial_tags: Set[str] = set() + self._project_name: Optional[str] = None + self._updated_at: Optional["datetime.datetime"] = None + self.name: Optional[str] = name + self.owner_id: Optional[str] = None + self.project_id: str = project_id + self.tags: Set[str] = set() + self.description: Optional[str] = None self._connections = None self._permissions = None @@ -39,11 +46,11 @@ def permissions(self): return self._permissions() @property - def webpage_url(self): + def webpage_url(self) -> Optional[str]: return self._webpage_url @property - def created_at(self): + def created_at(self) -> Optional["datetime.datetime"]: return self._created_at @property @@ -54,36 +61,36 @@ def dqws(self): return self._data_quality_warnings() @property - def id(self): + def id(self) -> Optional[str]: return self._id @property - def project_id(self): + def project_id(self) -> str: return self._project_id @project_id.setter @property_not_nullable - def project_id(self, value): + def project_id(self, value: str) -> None: self._project_id = value @property - def description(self): + def description(self) -> Optional[str]: return self._description @description.setter - def description(self, value): + def description(self, value: str) -> None: self._description = value @property - def project_name(self): + def project_name(self) -> Optional[str]: return self._project_name @property - def flow_type(self): + def flow_type(self): # What is this? It doesn't seem to get set anywhere. return self._flow_type @property - def updated_at(self): + def updated_at(self) -> Optional["datetime.datetime"]: return self._updated_at def _set_connections(self, connections): @@ -97,7 +104,7 @@ def _set_data_quality_warnings(self, dqws): def _parse_common_elements(self, flow_xml, ns): if not isinstance(flow_xml, ET.Element): - flow_xml = ET.fromstring(flow_xml).find(".//t:flow", namespaces=ns) + flow_xml = fromstring(flow_xml).find(".//t:flow", namespaces=ns) if flow_xml is not None: ( _, @@ -161,9 +168,9 @@ def _set_values( self.owner_id = owner_id @classmethod - def from_response(cls, resp, ns): + def from_response(cls, resp, ns) -> List["FlowItem"]: all_flow_items = list() - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) all_flow_xml = parsed_response.findall(".//t:flow", namespaces=ns) for flow_xml in all_flow_xml: diff --git a/tableauserverclient/models/flow_run_item.py b/tableauserverclient/models/flow_run_item.py index 251c667b1..f6ce3d0d5 100644 --- a/tableauserverclient/models/flow_run_item.py +++ b/tableauserverclient/models/flow_run_item.py @@ -1,54 +1,52 @@ -import xml.etree.ElementTree as ET -from ..datetime_helpers import parse_datetime import itertools +from typing import Dict, List, Optional, Type, TYPE_CHECKING + +from defusedxml.ElementTree import fromstring + +from ..datetime_helpers import parse_datetime + +if TYPE_CHECKING: + from datetime import datetime class FlowRunItem(object): def __init__(self) -> None: - self._id=None - self._flow_id=None - self._status=None - self._started_at=None - self._completed_at=None - self._progress=None - self._background_job_id=None - - + self._id: str = "" + self._flow_id: Optional[str] = None + self._status: Optional[str] = None + self._started_at: Optional["datetime"] = None + self._completed_at: Optional["datetime"] = None + self._progress: Optional[str] = None + self._background_job_id: Optional[str] = None + @property - def id(self): + def id(self) -> str: return self._id - @property - def flow_id(self): + def flow_id(self) -> Optional[str]: return self._flow_id - @property - def status(self): + def status(self) -> Optional[str]: return self._status - @property - def started_at(self): + def started_at(self) -> Optional["datetime"]: return self._started_at - @property - def completed_at(self): + def completed_at(self) -> Optional["datetime"]: return self._completed_at - @property - def progress(self): + def progress(self) -> Optional[str]: return self._progress - @property - def background_job_id(self): + def background_job_id(self) -> Optional[str]: return self._background_job_id - def _set_values( self, id, @@ -74,14 +72,13 @@ def _set_values( if background_job_id is not None: self._background_job_id = background_job_id - @classmethod - def from_response(cls, resp, ns): + def from_response(cls: Type["FlowRunItem"], resp: bytes, ns: Optional[Dict]) -> List["FlowRunItem"]: all_flowrun_items = list() - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) all_flowrun_xml = itertools.chain( parsed_response.findall(".//t:flowRun[@id]", namespaces=ns), - parsed_response.findall(".//t:flowRuns[@id]", namespaces=ns) + parsed_response.findall(".//t:flowRuns[@id]", namespaces=ns), ) for flowrun_xml in all_flowrun_xml: @@ -91,16 +88,15 @@ def from_response(cls, resp, ns): all_flowrun_items.append(flowrun_item) return all_flowrun_items - @staticmethod def _parse_element(flowrun_xml, ns): result = {} - result['id'] = flowrun_xml.get("id", None) - result['flow_id'] = flowrun_xml.get("flowId", None) - result['status'] = flowrun_xml.get("status", None) - result['started_at'] = parse_datetime(flowrun_xml.get("startedAt", None)) - result['completed_at'] = parse_datetime(flowrun_xml.get("completedAt", None)) - result['progress'] = flowrun_xml.get("progress", None) - result['background_job_id'] = flowrun_xml.get("backgroundJobId", None) + result["id"] = flowrun_xml.get("id", None) + result["flow_id"] = flowrun_xml.get("flowId", None) + result["status"] = flowrun_xml.get("status", None) + result["started_at"] = parse_datetime(flowrun_xml.get("startedAt", None)) + result["completed_at"] = parse_datetime(flowrun_xml.get("completedAt", None)) + result["progress"] = flowrun_xml.get("progress", None) + result["background_job_id"] = flowrun_xml.get("backgroundJobId", None) return result diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index fdc06604b..6fcf18544 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -1,83 +1,89 @@ -import xml.etree.ElementTree as ET +from typing import Callable, List, Optional, TYPE_CHECKING + +from defusedxml.ElementTree import fromstring + from .exceptions import UnpopulatedPropertyError from .property_decorators import property_not_empty, property_is_enum from .reference_item import ResourceReference from .user_item import UserItem +if TYPE_CHECKING: + from ..server import Pager + class GroupItem(object): - tag_name = "group" + tag_name: str = "group" class LicenseMode: - onLogin = "onLogin" - onSync = "onSync" + onLogin: str = "onLogin" + onSync: str = "onSync" - def __init__(self, name=None, domain_name=None): - self._id = None - self._license_mode = None - self._minimum_site_role = None - self._users = None - self.name = name - self.domain_name = domain_name + def __init__(self, name=None, domain_name=None) -> None: + self._id: Optional[str] = None + self._license_mode: Optional[str] = None + self._minimum_site_role: Optional[str] = None + self._users: Optional[Callable[..., "Pager"]] = None + self.name: Optional[str] = name + self.domain_name: Optional[str] = domain_name @property - def domain_name(self): + def domain_name(self) -> Optional[str]: return self._domain_name @domain_name.setter - def domain_name(self, value): + def domain_name(self, value: str) -> None: self._domain_name = value @property - def id(self): + def id(self) -> Optional[str]: return self._id @property - def name(self): + def name(self) -> Optional[str]: return self._name @name.setter @property_not_empty - def name(self, value): + def name(self, value: str) -> None: self._name = value @property - def license_mode(self): + def license_mode(self) -> Optional[str]: return self._license_mode @license_mode.setter @property_is_enum(LicenseMode) - def license_mode(self, value): + def license_mode(self, value: str) -> None: self._license_mode = value @property - def minimum_site_role(self): + def minimum_site_role(self) -> Optional[str]: return self._minimum_site_role @minimum_site_role.setter @property_is_enum(UserItem.Roles) - def minimum_site_role(self, value): + def minimum_site_role(self, value: str) -> None: self._minimum_site_role = value @property - def users(self): + def users(self) -> "Pager": if self._users is None: error = "Group must be populated with users first." raise UnpopulatedPropertyError(error) # Each call to `.users` should create a new pager, this just runs the callable return self._users() - def to_reference(self): + def to_reference(self) -> ResourceReference: return ResourceReference(id_=self.id, tag_name=self.tag_name) - def _set_users(self, users): + def _set_users(self, users: Callable[..., "Pager"]) -> None: self._users = users @classmethod - def from_response(cls, resp, ns): + def from_response(cls, resp, ns) -> List["GroupItem"]: all_group_items = list() - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) all_group_xml = parsed_response.findall(".//t:group", namespaces=ns) for group_xml in all_group_xml: name = group_xml.get("name", None) @@ -100,5 +106,5 @@ def from_response(cls, resp, ns): return all_group_items @staticmethod - def as_reference(id_): + def as_reference(id_: str) -> ResourceReference: return ResourceReference(id_, GroupItem.tag_name) diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index 320e01ef2..cf5e70353 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -84,8 +84,9 @@ def _interval_type_pairs(self): class DailyInterval(object): - def __init__(self, start_time): + def __init__(self, start_time, *interval_values): self.start_time = start_time + self.interval = interval_values @property def _frequency(self): @@ -101,6 +102,14 @@ def start_time(self): def start_time(self, value): self._start_time = value + @property + def interval(self): + return self._interval + + @interval.setter + def interval(self, interval): + self._interval = interval + class WeeklyInterval(object): def __init__(self, start_time, *interval_values): diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 8c21b24e6..e05c42e22 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -1,7 +1,13 @@ -import xml.etree.ElementTree as ET +from typing import List, Optional, TYPE_CHECKING + +from defusedxml.ElementTree import fromstring + from .flow_run_item import FlowRunItem from ..datetime_helpers import parse_datetime +if TYPE_CHECKING: + import datetime + class JobItem(object): class FinishCode: @@ -9,23 +15,23 @@ class FinishCode: Status codes as documented on https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#query_job """ - Success = 0 - Failed = 1 - Cancelled = 2 + Success: int = 0 + Failed: int = 1 + Cancelled: int = 2 def __init__( self, - id_, - job_type, - progress, - created_at, - started_at=None, - completed_at=None, - finish_code=0, - notes=None, - mode=None, - flow_run=None, + id_: str, + job_type: str, + progress: str, + created_at: "datetime.datetime", + started_at: Optional["datetime.datetime"] = None, + completed_at: Optional["datetime.datetime"] = None, + finish_code: int = 0, + notes: Optional[List[str]] = None, + mode: Optional[str] = None, + flow_run: Optional[FlowRunItem] = None, ): self._id = id_ self._type = job_type @@ -34,48 +40,48 @@ def __init__( self._started_at = started_at self._completed_at = completed_at self._finish_code = finish_code - self._notes = notes or [] + self._notes: List[str] = notes or [] self._mode = mode self._flow_run = flow_run @property - def id(self): + def id(self) -> str: return self._id @property - def type(self): + def type(self) -> str: return self._type @property - def progress(self): + def progress(self) -> str: return self._progress @property - def created_at(self): + def created_at(self) -> "datetime.datetime": return self._created_at @property - def started_at(self): + def started_at(self) -> Optional["datetime.datetime"]: return self._started_at @property - def completed_at(self): + def completed_at(self) -> Optional["datetime.datetime"]: return self._completed_at @property - def finish_code(self): + def finish_code(self) -> int: return self._finish_code @property - def notes(self): + def notes(self) -> List[str]: return self._notes @property - def mode(self): + def mode(self) -> Optional[str]: return self._mode @mode.setter - def mode(self, value): + def mode(self, value: str) -> None: # check for valid data here self._mode = value @@ -94,8 +100,8 @@ def __repr__(self): ) @classmethod - def from_response(cls, xml, ns): - parsed_response = ET.fromstring(xml) + def from_response(cls, xml, ns) -> List["JobItem"]: + parsed_response = fromstring(xml) all_tasks_xml = parsed_response.findall(".//t:job", namespaces=ns) all_tasks = [JobItem._parse_element(x, ns) for x in all_tasks_xml] @@ -136,23 +142,23 @@ def _parse_element(cls, element, ns): class BackgroundJobItem(object): class Status: - Pending = "Pending" - InProgress = "InProgress" - Success = "Success" - Failed = "Failed" - Cancelled = "Cancelled" + Pending: str = "Pending" + InProgress: str = "InProgress" + Success: str = "Success" + Failed: str = "Failed" + Cancelled: str = "Cancelled" def __init__( self, - id_, - created_at, - priority, - job_type, - status, - title=None, - subtitle=None, - started_at=None, - ended_at=None, + id_: str, + created_at: "datetime.datetime", + priority: int, + job_type: str, + status: str, + title: Optional[str] = None, + subtitle: Optional[str] = None, + started_at: Optional["datetime.datetime"] = None, + ended_at: Optional["datetime.datetime"] = None, ): self._id = id_ self._type = job_type @@ -165,50 +171,50 @@ def __init__( self._subtitle = subtitle @property - def id(self): + def id(self) -> str: return self._id @property - def name(self): + def name(self) -> Optional[str]: """For API consistency - all other resource endpoints have a name attribute which is used to display what they are. Alias title as name to allow consistent handling of resources in the list sample.""" return self._title @property - def status(self): + def status(self) -> str: return self._status @property - def type(self): + def type(self) -> str: return self._type @property - def created_at(self): + def created_at(self) -> "datetime.datetime": return self._created_at @property - def started_at(self): + def started_at(self) -> Optional["datetime.datetime"]: return self._started_at @property - def ended_at(self): + def ended_at(self) -> Optional["datetime.datetime"]: return self._ended_at @property - def title(self): + def title(self) -> Optional[str]: return self._title @property - def subtitle(self): + def subtitle(self) -> Optional[str]: return self._subtitle @property - def priority(self): + def priority(self) -> int: return self._priority @classmethod - def from_response(cls, xml, ns): - parsed_response = ET.fromstring(xml) + def from_response(cls, xml, ns) -> List["BackgroundJobItem"]: + parsed_response = fromstring(xml) all_tasks_xml = parsed_response.findall(".//t:backgroundJob", namespaces=ns) return [cls._parse_element(x, ns) for x in all_tasks_xml] diff --git a/tableauserverclient/models/metric_item.py b/tableauserverclient/models/metric_item.py new file mode 100644 index 000000000..a54d1e30e --- /dev/null +++ b/tableauserverclient/models/metric_item.py @@ -0,0 +1,160 @@ +import xml.etree.ElementTree as ET +from ..datetime_helpers import parse_datetime +from .property_decorators import property_is_boolean, property_is_datetime +from .tag_item import TagItem +from typing import List, Optional, TYPE_CHECKING, Set + +if TYPE_CHECKING: + from datetime import datetime + + +class MetricItem(object): + def __init__(self, name: Optional[str] = None): + self._id: Optional[str] = None + self._name: Optional[str] = name + self._description: Optional[str] = None + self._webpage_url: Optional[str] = None + self._created_at: Optional["datetime"] = None + self._updated_at: Optional["datetime"] = None + self._suspended: Optional[bool] = None + self._project_id: Optional[str] = None + self._project_name: Optional[str] = None + self._owner_id: Optional[str] = None + self._view_id: Optional[str] = None + self._initial_tags: Set[str] = set() + self.tags: Set[str] = set() + + @property + def id(self) -> Optional[str]: + return self._id + + @id.setter + def id(self, value: Optional[str]) -> None: + self._id = value + + @property + def name(self) -> Optional[str]: + return self._name + + @name.setter + def name(self, value: Optional[str]) -> None: + self._name = value + + @property + def description(self) -> Optional[str]: + return self._description + + @description.setter + def description(self, value: Optional[str]) -> None: + self._description = value + + @property + def webpage_url(self) -> Optional[str]: + return self._webpage_url + + @property + def created_at(self) -> Optional["datetime"]: + return self._created_at + + @created_at.setter + @property_is_datetime + def created_at(self, value: "datetime") -> None: + self._created_at = value + + @property + def updated_at(self) -> Optional["datetime"]: + return self._updated_at + + @updated_at.setter + @property_is_datetime + def updated_at(self, value: "datetime") -> None: + self._updated_at = value + + @property + def suspended(self) -> Optional[bool]: + return self._suspended + + @suspended.setter + @property_is_boolean + def suspended(self, value: bool) -> None: + self._suspended = value + + @property + def project_id(self) -> Optional[str]: + return self._project_id + + @project_id.setter + def project_id(self, value: Optional[str]) -> None: + self._project_id = value + + @property + def project_name(self) -> Optional[str]: + return self._project_name + + @project_name.setter + def project_name(self, value: Optional[str]) -> None: + self._project_name = value + + @property + def owner_id(self) -> Optional[str]: + return self._owner_id + + @owner_id.setter + def owner_id(self, value: Optional[str]) -> None: + self._owner_id = value + + @property + def view_id(self) -> Optional[str]: + return self._view_id + + @view_id.setter + def view_id(self, value: Optional[str]) -> None: + self._view_id = value + + def __repr__(self): + return "".format(**vars(self)) + + @classmethod + def from_response( + cls, + resp: bytes, + ns, + ) -> List["MetricItem"]: + all_metric_items = list() + parsed_response = ET.fromstring(resp) + all_metric_xml = parsed_response.findall(".//t:metric", namespaces=ns) + for metric_xml in all_metric_xml: + metric_item = cls() + metric_item._id = metric_xml.get("id", None) + metric_item._name = metric_xml.get("name", None) + metric_item._description = metric_xml.get("description", None) + metric_item._webpage_url = metric_xml.get("webpageUrl", None) + metric_item._created_at = parse_datetime(metric_xml.get("createdAt", None)) + metric_item._updated_at = parse_datetime(metric_xml.get("updatedAt", None)) + metric_item._suspended = string_to_bool(metric_xml.get("suspended", "")) + for owner in metric_xml.findall(".//t:owner", namespaces=ns): + metric_item._owner_id = owner.get("id", None) + + for project in metric_xml.findall(".//t:project", namespaces=ns): + metric_item._project_id = project.get("id", None) + metric_item._project_name = project.get("name", None) + + for view in metric_xml.findall(".//t:underlyingView", namespaces=ns): + metric_item._view_id = view.get("id", None) + + tags = set() + tags_elem = metric_xml.find(".//t:tags", namespaces=ns) + if tags_elem is not None: + all_tags = TagItem.from_xml_element(tags_elem, ns) + tags = all_tags + + metric_item.tags = tags + metric_item._initial_tags = tags + + all_metric_items.append(metric_item) + return all_metric_items + + +# Used to convert string represented boolean to a boolean type +def string_to_bool(s: str) -> bool: + return s.lower() == "true" diff --git a/tableauserverclient/models/pagination_item.py b/tableauserverclient/models/pagination_item.py index df9ca26e6..2cb89dc5e 100644 --- a/tableauserverclient/models/pagination_item.py +++ b/tableauserverclient/models/pagination_item.py @@ -1,4 +1,4 @@ -import xml.etree.ElementTree as ET +from defusedxml.ElementTree import fromstring class PaginationItem(object): @@ -8,20 +8,20 @@ def __init__(self): self._total_available = None @property - def page_number(self): + def page_number(self) -> int: return self._page_number @property - def page_size(self): + def page_size(self) -> int: return self._page_size @property - def total_available(self): + def total_available(self) -> int: return self._total_available @classmethod - def from_response(cls, resp, ns): - parsed_response = ET.fromstring(resp) + def from_response(cls, resp, ns) -> "PaginationItem": + parsed_response = fromstring(resp) pagination_xml = parsed_response.find("t:pagination", namespaces=ns) pagination_item = cls() if pagination_xml is not None: @@ -31,7 +31,7 @@ def from_response(cls, resp, ns): return pagination_item @classmethod - def from_single_page_list(cls, single_page_list): + def from_single_page_list(cls, single_page_list) -> "PaginationItem": item = cls() item._page_number = 1 item._page_size = len(single_page_list) diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 113e8525e..71ca56248 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -1,12 +1,18 @@ -import xml.etree.ElementTree as ET import logging +import xml.etree.ElementTree as ET -from .exceptions import UnknownGranteeTypeError -from .user_item import UserItem +from defusedxml.ElementTree import fromstring +from .exceptions import UnknownGranteeTypeError, UnpopulatedPropertyError from .group_item import GroupItem +from .user_item import UserItem logger = logging.getLogger("tableau.models.permissions_item") +from typing import Dict, List, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from .reference_item import ResourceReference + class Permission: class Mode: @@ -42,19 +48,19 @@ class Resource: class PermissionsRule(object): - def __init__(self, grantee, capabilities): + def __init__(self, grantee: "ResourceReference", capabilities: Dict[str, str]) -> None: self.grantee = grantee self.capabilities = capabilities @classmethod - def from_response(cls, resp, ns=None): - parsed_response = ET.fromstring(resp) + def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: + parsed_response = fromstring(resp) rules = [] permissions_rules_list_xml = parsed_response.findall(".//t:granteeCapabilities", namespaces=ns) for grantee_capability_xml in permissions_rules_list_xml: - capability_dict = {} + capability_dict: Dict[str, str] = {} grantee = PermissionsRule._parse_grantee_element(grantee_capability_xml, ns) @@ -62,7 +68,11 @@ def from_response(cls, resp, ns=None): name = capability_xml.get("name") mode = capability_xml.get("mode") - capability_dict[name] = mode + if name is None or mode is None: + logger.error("Capability was not valid: ", capability_xml) + raise UnpopulatedPropertyError() + else: + capability_dict[name] = mode rule = PermissionsRule(grantee, capability_dict) rules.append(rule) @@ -70,7 +80,7 @@ def from_response(cls, resp, ns=None): return rules @staticmethod - def _parse_grantee_element(grantee_capability_xml, ns): + def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[Dict[str, str]]) -> "ResourceReference": """Use Xpath magic and some string splitting to get the right object type from the xml""" # Get the first element in the tree with an 'id' attribute diff --git a/tableauserverclient/models/personal_access_token_auth.py b/tableauserverclient/models/personal_access_token_auth.py index a95972164..e1744766d 100644 --- a/tableauserverclient/models/personal_access_token_auth.py +++ b/tableauserverclient/models/personal_access_token_auth.py @@ -1,8 +1,8 @@ class PersonalAccessTokenAuth(object): - def __init__(self, token_name, personal_access_token, site_id=""): + def __init__(self, token_name, personal_access_token, site_id=None): self.token_name = token_name self.personal_access_token = personal_access_token - self.site_id = site_id + self.site_id = site_id if site_id is not None else "" # Personal Access Tokens doesn't support impersonation. self.user_id_to_impersonate = None diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 3a7d01143..177b3e016 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -1,24 +1,31 @@ import xml.etree.ElementTree as ET +from typing import List, Optional -from .permissions_item import Permission +from defusedxml.ElementTree import fromstring -from .property_decorators import property_is_enum, property_not_empty from .exceptions import UnpopulatedPropertyError +from .property_decorators import property_is_enum, property_not_empty class ProjectItem(object): class ContentPermissions: - LockedToProject = "LockedToProject" - ManagedByOwner = "ManagedByOwner" - LockedToProjectWithoutNested = "LockedToProjectWithoutNested" - - def __init__(self, name, description=None, content_permissions=None, parent_id=None): + LockedToProject: str = "LockedToProject" + ManagedByOwner: str = "ManagedByOwner" + LockedToProjectWithoutNested: str = "LockedToProjectWithoutNested" + + def __init__( + self, + name: str, + description: Optional[str] = None, + content_permissions: Optional[str] = None, + parent_id: Optional[str] = None, + ) -> None: self._content_permissions = None - self._id = None - self.description = description - self.name = name - self.content_permissions = content_permissions - self.parent_id = parent_id + self._id: Optional[str] = None + self.description: Optional[str] = description + self.name: str = name + self.content_permissions: Optional[str] = content_permissions + self.parent_id: Optional[str] = parent_id self._permissions = None self._default_workbook_permissions = None @@ -29,6 +36,11 @@ def __init__(self, name, description=None, content_permissions=None, parent_id=N def content_permissions(self): return self._content_permissions + @content_permissions.setter + @property_is_enum(ContentPermissions) + def content_permissions(self, value: Optional[str]) -> None: + self._content_permissions = value + @property def permissions(self): if self._permissions is None: @@ -57,30 +69,25 @@ def default_flow_permissions(self): raise UnpopulatedPropertyError(error) return self._default_flow_permissions() - @content_permissions.setter - @property_is_enum(ContentPermissions) - def content_permissions(self, value): - self._content_permissions = value - @property - def id(self): + def id(self) -> Optional[str]: return self._id @property - def name(self): + def name(self) -> str: return self._name @name.setter @property_not_empty - def name(self, value): + def name(self, value: str) -> None: self._name = value @property - def owner_id(self): + def owner_id(self) -> Optional[str]: return self._owner_id @owner_id.setter - def owner_id(self, value): + def owner_id(self, value: str) -> None: raise NotImplementedError("REST API does not currently support updating project owner.") def is_default(self): @@ -88,7 +95,7 @@ def is_default(self): def _parse_common_tags(self, project_xml, ns): if not isinstance(project_xml, ET.Element): - project_xml = ET.fromstring(project_xml).find(".//t:project", namespaces=ns) + project_xml = fromstring(project_xml).find(".//t:project", namespaces=ns) if project_xml is not None: ( @@ -126,9 +133,9 @@ def _set_default_permissions(self, permissions, content_type): ) @classmethod - def from_response(cls, resp, ns): + def from_response(cls, resp, ns) -> List["ProjectItem"]: all_project_items = list() - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) all_project_xml = parsed_response.findall(".//t:project", namespaces=ns) for project_xml in all_project_xml: diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index ea2a62380..2d7e01557 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -1,6 +1,7 @@ import datetime import re from functools import wraps + from ..datetime_helpers import parse_datetime diff --git a/tableauserverclient/models/revision_item.py b/tableauserverclient/models/revision_item.py new file mode 100644 index 000000000..024d45edd --- /dev/null +++ b/tableauserverclient/models/revision_item.py @@ -0,0 +1,82 @@ +from typing import List, Optional, TYPE_CHECKING + +from defusedxml.ElementTree import fromstring + +from ..datetime_helpers import parse_datetime + +if TYPE_CHECKING: + from datetime import datetime + + +class RevisionItem(object): + def __init__(self): + self._resource_id: Optional[str] = None + self._resource_name: Optional[str] = None + self._revision_number: Optional[str] = None + self._current: Optional[bool] = None + self._deleted: Optional[bool] = None + self._created_at: Optional["datetime"] = None + self._user_id: Optional[str] = None + self._user_name: Optional[str] = None + + @property + def resource_id(self) -> Optional[str]: + return self._resource_id + + @property + def resource_name(self) -> Optional[str]: + return self._resource_name + + @property + def revision_number(self) -> Optional[str]: + return self._revision_number + + @property + def current(self) -> Optional[bool]: + return self._current + + @property + def deleted(self) -> Optional[bool]: + return self._deleted + + @property + def created_at(self) -> Optional["datetime"]: + return self._created_at + + @property + def user_id(self) -> Optional[str]: + return self._user_id + + @property + def user_name(self) -> Optional[str]: + return self._user_name + + def __repr__(self): + return ( + "" + ).format(**self.__dict__) + + @classmethod + def from_response(cls, resp: bytes, ns, resource_item) -> List["RevisionItem"]: + all_revision_items = list() + parsed_response = fromstring(resp) + all_revision_xml = parsed_response.findall(".//t:revision", namespaces=ns) + for revision_xml in all_revision_xml: + revision_item = cls() + revision_item._resource_id = resource_item.id + revision_item._resource_name = resource_item.name + revision_item._revision_number = revision_xml.get("revisionNumber", None) + revision_item._current = string_to_bool(revision_xml.get("isCurrent", "")) + revision_item._deleted = string_to_bool(revision_xml.get("isDeleted", "")) + revision_item._created_at = parse_datetime(revision_xml.get("createdAt", None)) + for user in revision_xml.findall(".//t:user", namespaces=ns): + revision_item._user_id = user.get("id", None) + revision_item._user_name = user.get("name", None) + + all_revision_items.append(revision_item) + return all_revision_items + + +# Used to convert string represented boolean to a boolean type +def string_to_bool(s: str) -> bool: + return s.lower() == "true" diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index f8baf0749..828034d23 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -1,5 +1,8 @@ import xml.etree.ElementTree as ET from datetime import datetime +from typing import Optional, Union + +from defusedxml.ElementTree import fromstring from .interval_item import ( IntervalItem, @@ -15,6 +18,8 @@ ) from ..datetime_helpers import parse_datetime +Interval = Union[HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval] + class ScheduleItem(object): class Type: @@ -31,86 +36,86 @@ class State: Active = "Active" Suspended = "Suspended" - def __init__(self, name, priority, schedule_type, execution_order, interval_item): - self._created_at = None - self._end_schedule_at = None - self._id = None - self._next_run_at = None - self._state = None - self._updated_at = None - self.interval_item = interval_item - self.execution_order = execution_order - self.name = name - self.priority = priority - self.schedule_type = schedule_type + def __init__(self, name: str, priority: int, schedule_type: str, execution_order: str, interval_item: Interval): + self._created_at: Optional[datetime] = None + self._end_schedule_at: Optional[datetime] = None + self._id: Optional[str] = None + self._next_run_at: Optional[datetime] = None + self._state: Optional[str] = None + self._updated_at: Optional[datetime] = None + self.interval_item: Interval = interval_item + self.execution_order: str = execution_order + self.name: str = name + self.priority: int = priority + self.schedule_type: str = schedule_type def __repr__(self): - return ''.format(**self.__dict__) + return ''.format(**vars(self)) @property - def created_at(self): + def created_at(self) -> Optional[datetime]: return self._created_at @property - def end_schedule_at(self): + def end_schedule_at(self) -> Optional[datetime]: return self._end_schedule_at @property - def execution_order(self): + def execution_order(self) -> str: return self._execution_order @execution_order.setter @property_is_enum(ExecutionOrder) - def execution_order(self, value): + def execution_order(self, value: str): self._execution_order = value @property - def id(self): + def id(self) -> Optional[str]: return self._id @property - def name(self): + def name(self) -> str: return self._name @name.setter @property_not_nullable - def name(self, value): + def name(self, value: str): self._name = value @property - def next_run_at(self): + def next_run_at(self) -> Optional[datetime]: return self._next_run_at @property - def priority(self): + def priority(self) -> int: return self._priority @priority.setter @property_is_int(range=(1, 100)) - def priority(self, value): + def priority(self, value: int): self._priority = value @property - def schedule_type(self): + def schedule_type(self) -> str: return self._schedule_type @schedule_type.setter @property_is_enum(Type) @property_not_nullable - def schedule_type(self, value): + def schedule_type(self, value: str): self._schedule_type = value @property - def state(self): + def state(self) -> Optional[str]: return self._state @state.setter @property_is_enum(State) - def state(self, value): + def state(self, value: str): self._state = value @property - def updated_at(self): + def updated_at(self) -> Optional[datetime]: return self._updated_at @property @@ -119,7 +124,7 @@ def warnings(self): def _parse_common_tags(self, schedule_xml, ns): if not isinstance(schedule_xml, ET.Element): - schedule_xml = ET.fromstring(schedule_xml).find(".//t:schedule", namespaces=ns) + schedule_xml = fromstring(schedule_xml).find(".//t:schedule", namespaces=ns) if schedule_xml is not None: ( _, @@ -193,7 +198,7 @@ def _set_values( @classmethod def from_response(cls, resp, ns): - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) return cls.from_element(parsed_response, ns) @classmethod @@ -308,7 +313,7 @@ def _parse_element(schedule_xml, ns): @staticmethod def parse_add_to_schedule_response(response, ns): - parsed_response = ET.fromstring(response.content) + parsed_response = fromstring(response.content) warnings = ScheduleItem._read_warnings(parsed_response, ns) all_task_xml = parsed_response.findall(".//t:task", namespaces=ns) diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py index 1f6604662..d0ac5d292 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -1,4 +1,4 @@ -import xml.etree.ElementTree as ET +from defusedxml.ElementTree import fromstring class ServerInfoItem(object): @@ -7,6 +7,17 @@ def __init__(self, product_version, build_number, rest_api_version): self._build_number = build_number self._rest_api_version = rest_api_version + def __str__(self): + return ( + "ServerInfoItem: [product version: " + + self._product_version + + ", build no.:" + + self._build_number + + ", REST API version:" + + self.rest_api_version + + "]" + ) + @property def product_version(self): return self._product_version @@ -21,7 +32,7 @@ def rest_api_version(self): @classmethod def from_response(cls, resp, ns): - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) product_version_tag = parsed_response.find(".//t:productVersion", namespaces=ns) rest_api_version_tag = parsed_response.find(".//t:restApiVersion", namespaces=ns) diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index ab0211414..2d27acabf 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -1,4 +1,8 @@ +import warnings import xml.etree.ElementTree as ET + +from defusedxml.ElementTree import fromstring + from .property_decorators import ( property_is_enum, property_is_boolean, @@ -8,74 +12,80 @@ property_is_int, ) - VALID_CONTENT_URL_RE = r"^[a-zA-Z0-9_\-]*$" +from typing import List, Optional, Union + class SiteItem(object): + _user_quota: Optional[int] = None + _tier_creator_capacity: Optional[int] = None + _tier_explorer_capacity: Optional[int] = None + _tier_viewer_capacity: Optional[int] = None + class AdminMode: - ContentAndUsers = "ContentAndUsers" - ContentOnly = "ContentOnly" + ContentAndUsers: str = "ContentAndUsers" + ContentOnly: str = "ContentOnly" class State: - Active = "Active" - Suspended = "Suspended" + Active: str = "Active" + Suspended: str = "Suspended" def __init__( self, - name, - content_url, - admin_mode=None, - user_quota=None, - storage_quota=None, - disable_subscriptions=False, - subscribe_others_enabled=True, - revision_history_enabled=False, - revision_limit=25, - data_acceleration_mode=None, - flows_enabled=True, - cataloging_enabled=True, - editing_flows_enabled=True, - scheduling_flows_enabled=True, - allow_subscription_attachments=True, - guest_access_enabled=False, - cache_warmup_enabled=True, - commenting_enabled=True, - extract_encryption_mode=None, - request_access_enabled=False, - run_now_enabled=True, - tier_explorer_capacity=None, - tier_creator_capacity=None, - tier_viewer_capacity=None, - data_alerts_enabled=True, - commenting_mentions_enabled=True, - catalog_obfuscation_enabled=True, - flow_auto_save_enabled=True, - web_extraction_enabled=True, - metrics_content_type_enabled=True, - notify_site_admins_on_throttle=False, - authoring_enabled=True, - custom_subscription_email_enabled=False, - custom_subscription_email=False, - custom_subscription_footer_enabled=False, - custom_subscription_footer=False, - ask_data_mode="EnabledByDefault", - named_sharing_enabled=True, - mobile_biometrics_enabled=False, - sheet_image_enabled=True, - derived_permissions_enabled=False, - user_visibility_mode="FULL", - use_default_time_zone=True, + name: str, + content_url: str, + admin_mode: str = None, + user_quota: int = None, + storage_quota: int = None, + disable_subscriptions: bool = False, + subscribe_others_enabled: bool = True, + revision_history_enabled: bool = False, + revision_limit: int = 25, + data_acceleration_mode: Optional[str] = None, + flows_enabled: bool = True, + cataloging_enabled: bool = True, + editing_flows_enabled: bool = True, + scheduling_flows_enabled: bool = True, + allow_subscription_attachments: bool = True, + guest_access_enabled: bool = False, + cache_warmup_enabled: bool = True, + commenting_enabled: bool = True, + extract_encryption_mode: Optional[str] = None, + request_access_enabled: bool = False, + run_now_enabled: bool = True, + tier_explorer_capacity: Optional[int] = None, + tier_creator_capacity: Optional[int] = None, + tier_viewer_capacity: Optional[int] = None, + data_alerts_enabled: bool = True, + commenting_mentions_enabled: bool = True, + catalog_obfuscation_enabled: bool = True, + flow_auto_save_enabled: bool = True, + web_extraction_enabled: bool = True, + metrics_content_type_enabled: bool = True, + notify_site_admins_on_throttle: bool = False, + authoring_enabled: bool = True, + custom_subscription_email_enabled: bool = False, + custom_subscription_email: Union[str, bool] = False, + custom_subscription_footer_enabled: bool = False, + custom_subscription_footer: Union[str, bool] = False, + ask_data_mode: str = "EnabledByDefault", + named_sharing_enabled: bool = True, + mobile_biometrics_enabled: bool = False, + sheet_image_enabled: bool = True, + derived_permissions_enabled: bool = False, + user_visibility_mode: str = "FULL", + use_default_time_zone: bool = True, time_zone=None, - auto_suspend_refresh_enabled=True, - auto_suspend_refresh_inactivity_window=30, + auto_suspend_refresh_enabled: bool = True, + auto_suspend_refresh_inactivity_window: int = 30, ): self._admin_mode = None - self._id = None + self._id: Optional[str] = None self._num_users = None self._state = None self._status_reason = None - self._storage = None + self._storage: Optional[str] = None self.user_quota = user_quota self.storage_quota = storage_quota self.content_url = content_url @@ -124,16 +134,16 @@ def __init__( self.auto_suspend_refresh_inactivity_window = auto_suspend_refresh_inactivity_window @property - def admin_mode(self): + def admin_mode(self) -> Optional[str]: return self._admin_mode @admin_mode.setter @property_is_enum(AdminMode) - def admin_mode(self, value): + def admin_mode(self, value: Optional[str]) -> None: self._admin_mode = value @property - def content_url(self): + def content_url(self) -> str: return self._content_url @content_url.setter @@ -142,29 +152,29 @@ def content_url(self): VALID_CONTENT_URL_RE, "content_url can contain only letters, numbers, dashes, and underscores", ) - def content_url(self, value): + def content_url(self, value: str) -> None: self._content_url = value @property - def disable_subscriptions(self): + def disable_subscriptions(self) -> bool: return self._disable_subscriptions @disable_subscriptions.setter @property_is_boolean - def disable_subscriptions(self, value): + def disable_subscriptions(self, value: bool): self._disable_subscriptions = value @property - def id(self): + def id(self) -> Optional[str]: return self._id @property - def name(self): + def name(self) -> str: return self._name @name.setter @property_not_empty - def name(self, value): + def name(self, value: str): self._name = value @property @@ -172,337 +182,357 @@ def num_users(self): return self._num_users @property - def revision_history_enabled(self): + def revision_history_enabled(self) -> bool: return self._revision_history_enabled @revision_history_enabled.setter @property_is_boolean - def revision_history_enabled(self, value): + def revision_history_enabled(self, value: bool): self._revision_history_enabled = value @property - def revision_limit(self): + def revision_limit(self) -> int: return self._revision_limit @revision_limit.setter @property_is_int((2, 10000), allowed=[-1]) - def revision_limit(self, value): + def revision_limit(self, value: int): self._revision_limit = value @property - def state(self): + def state(self) -> Optional[str]: return self._state @state.setter @property_is_enum(State) - def state(self, value): + def state(self, value: Optional[str]) -> None: self._state = value @property - def status_reason(self): + def status_reason(self) -> Optional[str]: return self._status_reason @property - def storage(self): + def storage(self) -> Optional[str]: return self._storage @property - def subscribe_others_enabled(self): + def user_quota(self) -> Optional[int]: + if any((self.tier_creator_capacity, self.tier_explorer_capacity, self.tier_viewer_capacity)): + warnings.warn("Tiered license level is set. Returning None for user_quota") + return None + else: + return self._user_quota + + @user_quota.setter + def user_quota(self, value: Optional[int]) -> None: + if value is not None and any( + (self.tier_creator_capacity, self.tier_explorer_capacity, self.tier_viewer_capacity) + ): + raise ValueError( + "User quota conflicts with setting tiered license levels. " + "Use replace_license_tiers_with_user_quota to set those to None, " + "and set user_quota to the desired value." + ) + self._user_quota = value + + @property + def subscribe_others_enabled(self) -> bool: return self._subscribe_others_enabled @subscribe_others_enabled.setter @property_is_boolean - def subscribe_others_enabled(self, value): + def subscribe_others_enabled(self, value: bool) -> None: self._subscribe_others_enabled = value @property - def data_acceleration_mode(self): + def data_acceleration_mode(self) -> Optional[str]: return self._data_acceleration_mode @data_acceleration_mode.setter - def data_acceleration_mode(self, value): + def data_acceleration_mode(self, value: Optional[str]): self._data_acceleration_mode = value @property - def cataloging_enabled(self): + def cataloging_enabled(self) -> bool: return self._cataloging_enabled @cataloging_enabled.setter - def cataloging_enabled(self, value): + def cataloging_enabled(self, value: bool): self._cataloging_enabled = value @property - def flows_enabled(self): + def flows_enabled(self) -> bool: return self._flows_enabled @flows_enabled.setter @property_is_boolean - def flows_enabled(self, value): + def flows_enabled(self, value: bool) -> None: self._flows_enabled = value - def is_default(self): + def is_default(self) -> bool: return self.name.lower() == "default" @property - def editing_flows_enabled(self): + def editing_flows_enabled(self) -> bool: return self._editing_flows_enabled @editing_flows_enabled.setter @property_is_boolean - def editing_flows_enabled(self, value): + def editing_flows_enabled(self, value: bool) -> None: self._editing_flows_enabled = value @property - def scheduling_flows_enabled(self): + def scheduling_flows_enabled(self) -> bool: return self._scheduling_flows_enabled @scheduling_flows_enabled.setter @property_is_boolean - def scheduling_flows_enabled(self, value): + def scheduling_flows_enabled(self, value: bool): self._scheduling_flows_enabled = value @property - def allow_subscription_attachments(self): + def allow_subscription_attachments(self) -> bool: return self._allow_subscription_attachments @allow_subscription_attachments.setter @property_is_boolean - def allow_subscription_attachments(self, value): + def allow_subscription_attachments(self, value: bool): self._allow_subscription_attachments = value @property - def guest_access_enabled(self): + def guest_access_enabled(self) -> bool: return self._guest_access_enabled @guest_access_enabled.setter @property_is_boolean - def guest_access_enabled(self, value): + def guest_access_enabled(self, value: bool) -> None: self._guest_access_enabled = value @property - def cache_warmup_enabled(self): + def cache_warmup_enabled(self) -> bool: return self._cache_warmup_enabled @cache_warmup_enabled.setter @property_is_boolean - def cache_warmup_enabled(self, value): + def cache_warmup_enabled(self, value: bool): self._cache_warmup_enabled = value @property - def commenting_enabled(self): + def commenting_enabled(self) -> bool: return self._commenting_enabled @commenting_enabled.setter @property_is_boolean - def commenting_enabled(self, value): + def commenting_enabled(self, value: bool): self._commenting_enabled = value @property - def extract_encryption_mode(self): + def extract_encryption_mode(self) -> Optional[str]: return self._extract_encryption_mode @extract_encryption_mode.setter - def extract_encryption_mode(self, value): + def extract_encryption_mode(self, value: Optional[str]): self._extract_encryption_mode = value @property - def request_access_enabled(self): + def request_access_enabled(self) -> bool: return self._request_access_enabled @request_access_enabled.setter @property_is_boolean - def request_access_enabled(self, value): + def request_access_enabled(self, value: bool) -> None: self._request_access_enabled = value @property - def run_now_enabled(self): + def run_now_enabled(self) -> bool: return self._run_now_enabled @run_now_enabled.setter @property_is_boolean - def run_now_enabled(self, value): + def run_now_enabled(self, value: bool): self._run_now_enabled = value @property - def tier_explorer_capacity(self): + def tier_explorer_capacity(self) -> Optional[int]: return self._tier_explorer_capacity @tier_explorer_capacity.setter - def tier_explorer_capacity(self, value): + def tier_explorer_capacity(self, value: Optional[int]) -> None: self._tier_explorer_capacity = value @property - def tier_creator_capacity(self): + def tier_creator_capacity(self) -> Optional[int]: return self._tier_creator_capacity @tier_creator_capacity.setter - def tier_creator_capacity(self, value): + def tier_creator_capacity(self, value: Optional[int]) -> None: self._tier_creator_capacity = value @property - def tier_viewer_capacity(self): + def tier_viewer_capacity(self) -> Optional[int]: return self._tier_viewer_capacity @tier_viewer_capacity.setter - def tier_viewer_capacity(self, value): + def tier_viewer_capacity(self, value: Optional[int]): self._tier_viewer_capacity = value @property - def data_alerts_enabled(self): + def data_alerts_enabled(self) -> bool: return self._data_alerts_enabled @data_alerts_enabled.setter @property_is_boolean - def data_alerts_enabled(self, value): + def data_alerts_enabled(self, value: bool) -> None: self._data_alerts_enabled = value @property - def commenting_mentions_enabled(self): + def commenting_mentions_enabled(self) -> bool: return self._commenting_mentions_enabled @commenting_mentions_enabled.setter @property_is_boolean - def commenting_mentions_enabled(self, value): + def commenting_mentions_enabled(self, value: bool) -> None: self._commenting_mentions_enabled = value @property - def catalog_obfuscation_enabled(self): + def catalog_obfuscation_enabled(self) -> bool: return self._catalog_obfuscation_enabled @catalog_obfuscation_enabled.setter @property_is_boolean - def catalog_obfuscation_enabled(self, value): + def catalog_obfuscation_enabled(self, value: bool) -> None: self._catalog_obfuscation_enabled = value @property - def flow_auto_save_enabled(self): + def flow_auto_save_enabled(self) -> bool: return self._flow_auto_save_enabled @flow_auto_save_enabled.setter @property_is_boolean - def flow_auto_save_enabled(self, value): + def flow_auto_save_enabled(self, value: bool) -> None: self._flow_auto_save_enabled = value @property - def web_extraction_enabled(self): + def web_extraction_enabled(self) -> bool: return self._web_extraction_enabled @web_extraction_enabled.setter @property_is_boolean - def web_extraction_enabled(self, value): + def web_extraction_enabled(self, value: bool) -> None: self._web_extraction_enabled = value @property - def metrics_content_type_enabled(self): + def metrics_content_type_enabled(self) -> bool: return self._metrics_content_type_enabled @metrics_content_type_enabled.setter @property_is_boolean - def metrics_content_type_enabled(self, value): + def metrics_content_type_enabled(self, value: bool) -> None: self._metrics_content_type_enabled = value @property - def notify_site_admins_on_throttle(self): + def notify_site_admins_on_throttle(self) -> bool: return self._notify_site_admins_on_throttle @notify_site_admins_on_throttle.setter @property_is_boolean - def notify_site_admins_on_throttle(self, value): + def notify_site_admins_on_throttle(self, value: bool) -> None: self._notify_site_admins_on_throttle = value @property - def authoring_enabled(self): + def authoring_enabled(self) -> bool: return self._authoring_enabled @authoring_enabled.setter @property_is_boolean - def authoring_enabled(self, value): + def authoring_enabled(self, value: bool) -> None: self._authoring_enabled = value @property - def custom_subscription_email_enabled(self): + def custom_subscription_email_enabled(self) -> bool: return self._custom_subscription_email_enabled @custom_subscription_email_enabled.setter @property_is_boolean - def custom_subscription_email_enabled(self, value): + def custom_subscription_email_enabled(self, value: bool) -> None: self._custom_subscription_email_enabled = value @property - def custom_subscription_email(self): + def custom_subscription_email(self) -> Union[str, bool]: return self._custom_subscription_email @custom_subscription_email.setter - def custom_subscription_email(self, value): + def custom_subscription_email(self, value: Union[str, bool]): self._custom_subscription_email = value @property - def custom_subscription_footer_enabled(self): + def custom_subscription_footer_enabled(self) -> bool: return self._custom_subscription_footer_enabled @custom_subscription_footer_enabled.setter @property_is_boolean - def custom_subscription_footer_enabled(self, value): + def custom_subscription_footer_enabled(self, value: bool) -> None: self._custom_subscription_footer_enabled = value @property - def custom_subscription_footer(self): + def custom_subscription_footer(self) -> Union[str, bool]: return self._custom_subscription_footer @custom_subscription_footer.setter - def custom_subscription_footer(self, value): + def custom_subscription_footer(self, value: Union[str, bool]) -> None: self._custom_subscription_footer = value @property - def ask_data_mode(self): + def ask_data_mode(self) -> str: return self._ask_data_mode @ask_data_mode.setter - def ask_data_mode(self, value): + def ask_data_mode(self, value: str) -> None: self._ask_data_mode = value @property - def named_sharing_enabled(self): + def named_sharing_enabled(self) -> bool: return self._named_sharing_enabled @named_sharing_enabled.setter @property_is_boolean - def named_sharing_enabled(self, value): + def named_sharing_enabled(self, value: bool) -> None: self._named_sharing_enabled = value @property - def mobile_biometrics_enabled(self): + def mobile_biometrics_enabled(self) -> bool: return self._mobile_biometrics_enabled @mobile_biometrics_enabled.setter @property_is_boolean - def mobile_biometrics_enabled(self, value): + def mobile_biometrics_enabled(self, value: bool) -> None: self._mobile_biometrics_enabled = value @property - def sheet_image_enabled(self): + def sheet_image_enabled(self) -> bool: return self._sheet_image_enabled @sheet_image_enabled.setter @property_is_boolean - def sheet_image_enabled(self, value): + def sheet_image_enabled(self, value: bool) -> None: self._sheet_image_enabled = value @property - def derived_permissions_enabled(self): + def derived_permissions_enabled(self) -> bool: return self._derived_permissions_enabled @derived_permissions_enabled.setter @property_is_boolean - def derived_permissions_enabled(self, value): + def derived_permissions_enabled(self, value: bool) -> None: self._derived_permissions_enabled = value @property - def user_visibility_mode(self): + def user_visibility_mode(self) -> str: return self._user_visibility_mode @user_visibility_mode.setter - def user_visibility_mode(self, value): + def user_visibility_mode(self, value: str): self._user_visibility_mode = value @property @@ -530,16 +560,22 @@ def auto_suspend_refresh_inactivity_window(self, value): self._auto_suspend_refresh_inactivity_window = value @property - def auto_suspend_refresh_enabled(self): + def auto_suspend_refresh_enabled(self) -> bool: return self._auto_suspend_refresh_enabled @auto_suspend_refresh_enabled.setter - def auto_suspend_refresh_enabled(self, value): + def auto_suspend_refresh_enabled(self, value: bool): self._auto_suspend_refresh_enabled = value + def replace_license_tiers_with_user_quota(self, value: int) -> None: + self.tier_creator_capacity = None + self.tier_explorer_capacity = None + self.tier_viewer_capacity = None + self.user_quota = value + def _parse_common_tags(self, site_xml, ns): if not isinstance(site_xml, ET.Element): - site_xml = ET.fromstring(site_xml).find(".//t:site", namespaces=ns) + site_xml = fromstring(site_xml).find(".//t:site", namespaces=ns) if site_xml is not None: ( _, @@ -723,7 +759,11 @@ def _set_values( if revision_history_enabled is not None: self._revision_history_enabled = revision_history_enabled if user_quota: - self.user_quota = user_quota + try: + self.user_quota = user_quota + except ValueError: + warnings.warn("Tiered license level is set. Setting user_quota to None.") + self.user_quota = None if storage_quota: self.storage_quota = storage_quota if revision_limit: @@ -808,9 +848,9 @@ def _set_values( self.auto_suspend_refresh_inactivity_window = auto_suspend_refresh_inactivity_window @classmethod - def from_response(cls, resp, ns): + def from_response(cls, resp, ns) -> List["SiteItem"]: all_site_items = list() - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) all_site_xml = parsed_response.findall(".//t:site", namespaces=ns) for site_xml in all_site_xml: ( @@ -1058,5 +1098,5 @@ def _parse_element(site_xml, ns): # Used to convert string represented boolean to a boolean type -def string_to_bool(s): +def string_to_bool(s: str) -> bool: return s.lower() == "true" diff --git a/tableauserverclient/models/subscription_item.py b/tableauserverclient/models/subscription_item.py index bc431ed77..e18adc6ae 100644 --- a/tableauserverclient/models/subscription_item.py +++ b/tableauserverclient/models/subscription_item.py @@ -1,10 +1,16 @@ -import xml.etree.ElementTree as ET -from .target import Target +from typing import List, Type, TYPE_CHECKING + +from defusedxml.ElementTree import fromstring + from .property_decorators import property_is_boolean +from .target import Target + +if TYPE_CHECKING: + from .target import Target class SubscriptionItem(object): - def __init__(self, subject, schedule_id, user_id, target): + def __init__(self, subject: str, schedule_id: str, user_id: str, target: "Target") -> None: self._id = None self.attach_image = True self.attach_pdf = False @@ -18,7 +24,7 @@ def __init__(self, subject, schedule_id, user_id, target): self.target = target self.user_id = user_id - def __repr__(self): + def __repr__(self) -> str: if self.id is not None: return " bool: return self._attach_image @attach_image.setter @property_is_boolean - def attach_image(self, value): + def attach_image(self, value: bool): self._attach_image = value @property - def attach_pdf(self): + def attach_pdf(self) -> bool: return self._attach_pdf @attach_pdf.setter @property_is_boolean - def attach_pdf(self, value): + def attach_pdf(self, value: bool) -> None: self._attach_pdf = value @property - def send_if_view_empty(self): + def send_if_view_empty(self) -> bool: return self._send_if_view_empty @send_if_view_empty.setter @property_is_boolean - def send_if_view_empty(self, value): + def send_if_view_empty(self, value: bool) -> None: self._send_if_view_empty = value @property - def suspended(self): + def suspended(self) -> bool: return self._suspended @suspended.setter @property_is_boolean - def suspended(self, value): + def suspended(self, value: bool) -> None: self._suspended = value @classmethod - def from_response(cls, xml, ns): - parsed_response = ET.fromstring(xml) + def from_response(cls: Type, xml: bytes, ns) -> List["SubscriptionItem"]: + parsed_response = fromstring(xml) all_subscriptions_xml = parsed_response.findall(".//t:subscription", namespaces=ns) all_subscriptions = [SubscriptionItem._parse_element(x, ns) for x in all_subscriptions_xml] @@ -126,5 +132,5 @@ def _parse_element(cls, element, ns): # Used to convert string represented boolean to a boolean type -def string_to_bool(s): +def string_to_bool(s: str) -> bool: return s.lower() == "true" diff --git a/tableauserverclient/models/table_item.py b/tableauserverclient/models/table_item.py index 2f47400f7..93edac63c 100644 --- a/tableauserverclient/models/table_item.py +++ b/tableauserverclient/models/table_item.py @@ -1,7 +1,7 @@ -import xml.etree.ElementTree as ET +from defusedxml.ElementTree import fromstring -from .property_decorators import property_not_empty, property_is_boolean from .exceptions import UnpopulatedPropertyError +from .property_decorators import property_not_empty, property_is_boolean class TableItem(object): @@ -128,7 +128,7 @@ def _set_permissions(self, permissions): @classmethod def from_response(cls, resp, ns): all_table_items = list() - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) all_table_xml = parsed_response.findall(".//t:table", namespaces=ns) for table_xml in all_table_xml: diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 01787de4e..e9760cbee 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -1,17 +1,19 @@ class TableauAuth(object): - def __init__(self, username, password, site=None, site_id="", user_id_to_impersonate=None): + def __init__(self, username, password, site=None, site_id=None, user_id_to_impersonate=None): if site is not None: import warnings warnings.warn( - 'TableauAuth(...site=""...) is deprecated, ' 'please use TableauAuth(...site_id=""...) instead.', + "TableauAuth(..., site=...) is deprecated, " "please use TableauAuth(..., site_id=...) instead.", DeprecationWarning, ) site_id = site + if password is None: + raise TabError("Must provide a password when using traditional authentication") self.user_id_to_impersonate = user_id_to_impersonate self.password = password - self.site_id = site_id + self.site_id = site_id if site_id is not None else "" self.username = username @property diff --git a/tableauserverclient/models/tag_item.py b/tableauserverclient/models/tag_item.py index 055b04634..f7568ae45 100644 --- a/tableauserverclient/models/tag_item.py +++ b/tableauserverclient/models/tag_item.py @@ -1,13 +1,15 @@ +from typing import Set import xml.etree.ElementTree as ET +from defusedxml.ElementTree import fromstring class TagItem(object): @classmethod - def from_response(cls, resp, ns): - return cls.from_xml_element(ET.fromstring(resp), ns) + def from_response(cls, resp: bytes, ns) -> Set[str]: + return cls.from_xml_element(fromstring(resp), ns) @classmethod - def from_xml_element(cls, parsed_response, ns): + def from_xml_element(cls, parsed_response: ET.Element, ns) -> Set[str]: all_tags = set() tag_elem = parsed_response.findall(".//t:tag", namespaces=ns) for tag_xml in tag_elem: diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index 65709d5c9..32299a853 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -1,6 +1,7 @@ -import xml.etree.ElementTree as ET -from .target import Target +from defusedxml.ElementTree import fromstring + from .schedule_item import ScheduleItem +from .target import Target from ..datetime_helpers import parse_datetime @@ -8,6 +9,7 @@ class TaskItem(object): class Type: ExtractRefresh = "extractRefresh" DataAcceleration = "dataAcceleration" + RunFlow = "runFlow" # This mapping is used to convert task type returned from server _TASK_TYPE_MAPPING = { @@ -43,7 +45,7 @@ def __repr__(self): @classmethod def from_response(cls, xml, ns, task_type=Type.ExtractRefresh): - parsed_response = ET.fromstring(xml) + parsed_response = fromstring(xml) all_tasks_xml = parsed_response.findall(".//t:task/t:{}".format(task_type), namespaces=ns) all_tasks = (TaskItem._parse_element(x, ns) for x in all_tasks_xml) diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 65abf4cb6..b94f33725 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -1,17 +1,25 @@ import xml.etree.ElementTree as ET +from datetime import datetime +from typing import Dict, List, Optional, TYPE_CHECKING + +from defusedxml.ElementTree import fromstring + from .exceptions import UnpopulatedPropertyError from .property_decorators import ( property_is_enum, property_not_empty, property_not_nullable, ) -from ..datetime_helpers import parse_datetime from .reference_item import ResourceReference +from ..datetime_helpers import parse_datetime + +if TYPE_CHECKING: + from ..server.pager import Pager class UserItem(object): - tag_name = "user" + tag_name: str = "user" class Roles: Interactor = "Interactor" @@ -39,23 +47,27 @@ class Auth: SAML = "SAML" ServerDefault = "ServerDefault" - def __init__(self, name=None, site_role=None, auth_setting=None): - self._auth_setting = None - self._domain_name = None - self._external_auth_user_id = None - self._id = None - self._last_login = None + def __init__( + self, name: Optional[str] = None, site_role: Optional[str] = None, auth_setting: Optional[str] = None + ) -> None: + self._auth_setting: Optional[str] = None + self._domain_name: Optional[str] = None + self._external_auth_user_id: Optional[str] = None + self._id: Optional[str] = None + self._last_login: Optional[datetime] = None self._workbooks = None - self._favorites = None + self._favorites: Optional[Dict[str, List]] = None self._groups = None - self.email = None - self.fullname = None - self.name = name - self.site_role = site_role - self.auth_setting = auth_setting + self.email: Optional[str] = None + self.fullname: Optional[str] = None + self.name: Optional[str] = name + self.site_role: Optional[str] = site_role + self.auth_setting: Optional[str] = auth_setting + + return None @property - def auth_setting(self): + def auth_setting(self) -> Optional[str]: return self._auth_setting @auth_setting.setter @@ -64,32 +76,32 @@ def auth_setting(self, value): self._auth_setting = value @property - def domain_name(self): + def domain_name(self) -> Optional[str]: return self._domain_name @property - def external_auth_user_id(self): + def external_auth_user_id(self) -> Optional[str]: return self._external_auth_user_id @property - def id(self): + def id(self) -> Optional[str]: return self._id @property - def last_login(self): + def last_login(self) -> Optional[datetime]: return self._last_login @property - def name(self): + def name(self) -> Optional[str]: return self._name @name.setter @property_not_empty - def name(self, value): + def name(self, value: str): self._name = value @property - def site_role(self): + def site_role(self) -> Optional[str]: return self._site_role @site_role.setter @@ -99,38 +111,38 @@ def site_role(self, value): self._site_role = value @property - def workbooks(self): + def workbooks(self) -> "Pager": if self._workbooks is None: error = "User item must be populated with workbooks first." raise UnpopulatedPropertyError(error) return self._workbooks() @property - def favorites(self): + def favorites(self) -> Dict[str, List]: if self._favorites is None: error = "User item must be populated with favorites first." raise UnpopulatedPropertyError(error) return self._favorites @property - def groups(self): + def groups(self) -> "Pager": if self._groups is None: error = "User item must be populated with groups first." raise UnpopulatedPropertyError(error) return self._groups() - def to_reference(self): + def to_reference(self) -> ResourceReference: return ResourceReference(id_=self.id, tag_name=self.tag_name) - def _set_workbooks(self, workbooks): + def _set_workbooks(self, workbooks) -> None: self._workbooks = workbooks - def _set_groups(self, groups): + def _set_groups(self, groups) -> None: self._groups = groups - def _parse_common_tags(self, user_xml, ns): + def _parse_common_tags(self, user_xml, ns) -> "UserItem": if not isinstance(user_xml, ET.Element): - user_xml = ET.fromstring(user_xml).find(".//t:user", namespaces=ns) + user_xml = fromstring(user_xml).find(".//t:user", namespaces=ns) if user_xml is not None: ( _, @@ -178,9 +190,9 @@ def _set_values( self._domain_name = domain_name @classmethod - def from_response(cls, resp, ns): + def from_response(cls, resp, ns) -> List["UserItem"]: all_user_items = [] - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) all_user_xml = parsed_response.findall(".//t:user", namespaces=ns) for user_xml in all_user_xml: ( @@ -210,7 +222,7 @@ def from_response(cls, resp, ns): return all_user_items @staticmethod - def as_reference(id_): + def as_reference(id_) -> ResourceReference: return ResourceReference(id_, UserItem.tag_name) @staticmethod @@ -241,5 +253,5 @@ def _parse_element(user_xml, ns): domain_name, ) - def __repr__(self): + def __repr__(self) -> str: return "".format(self.id, self.name, self.site_role) diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index f18acfc33..146f21077 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -1,29 +1,37 @@ -import xml.etree.ElementTree as ET -from ..datetime_helpers import parse_datetime +import copy +from typing import Callable, Iterable, List, Optional, Set, TYPE_CHECKING + +from defusedxml.ElementTree import fromstring + from .exceptions import UnpopulatedPropertyError from .tag_item import TagItem -import copy +from ..datetime_helpers import parse_datetime + +if TYPE_CHECKING: + from datetime import datetime + from .permissions_item import PermissionsRule class ViewItem(object): - def __init__(self): - self._content_url = None - self._created_at = None - self._id = None - self._image = None - self._initial_tags = set() - self._name = None - self._owner_id = None - self._preview_image = None - self._project_id = None - self._pdf = None - self._csv = None - self._total_views = None - self._sheet_type = None - self._updated_at = None - self._workbook_id = None - self._permissions = None - self.tags = set() + def __init__(self) -> None: + self._content_url: Optional[str] = None + self._created_at: Optional["datetime"] = None + self._id: Optional[str] = None + self._image: Optional[Callable[[], bytes]] = None + self._initial_tags: Set[str] = set() + self._name: Optional[str] = None + self._owner_id: Optional[str] = None + self._preview_image: Optional[Callable[[], bytes]] = None + self._project_id: Optional[str] = None + self._pdf: Optional[Callable[[], bytes]] = None + self._csv: Optional[Callable[[], Iterable[bytes]]] = None + self._excel: Optional[Callable[[], Iterable[bytes]]] = None + self._total_views: Optional[int] = None + self._sheet_type: Optional[str] = None + self._updated_at: Optional["datetime"] = None + self._workbook_id: Optional[str] = None + self._permissions: Optional[Callable[[], List["PermissionsRule"]]] = None + self.tags: Set[str] = set() def _set_preview_image(self, preview_image): self._preview_image = preview_image @@ -37,60 +45,70 @@ def _set_pdf(self, pdf): def _set_csv(self, csv): self._csv = csv + def _set_excel(self, excel): + self._excel = excel + @property - def content_url(self): + def content_url(self) -> Optional[str]: return self._content_url @property - def created_at(self): + def created_at(self) -> Optional["datetime"]: return self._created_at @property - def id(self): + def id(self) -> Optional[str]: return self._id @property - def image(self): + def image(self) -> bytes: if self._image is None: error = "View item must be populated with its png image first." raise UnpopulatedPropertyError(error) return self._image() @property - def name(self): + def name(self) -> Optional[str]: return self._name @property - def owner_id(self): + def owner_id(self) -> Optional[str]: return self._owner_id @property - def preview_image(self): + def preview_image(self) -> bytes: if self._preview_image is None: error = "View item must be populated with its preview image first." raise UnpopulatedPropertyError(error) return self._preview_image() @property - def project_id(self): + def project_id(self) -> Optional[str]: return self._project_id @property - def pdf(self): + def pdf(self) -> bytes: if self._pdf is None: error = "View item must be populated with its pdf first." raise UnpopulatedPropertyError(error) return self._pdf() @property - def csv(self): + def csv(self) -> Iterable[bytes]: if self._csv is None: error = "View item must be populated with its csv first." raise UnpopulatedPropertyError(error) return self._csv() @property - def sheet_type(self): + def excel(self) -> Iterable[bytes]: + if self._excel is None: + error = "View item must be populated with its excel first." + raise UnpopulatedPropertyError(error) + return self._excel() + + @property + def sheet_type(self) -> Optional[str]: return self._sheet_type @property @@ -101,29 +119,29 @@ def total_views(self): return self._total_views @property - def updated_at(self): + def updated_at(self) -> Optional["datetime"]: return self._updated_at @property - def workbook_id(self): + def workbook_id(self) -> Optional[str]: return self._workbook_id @property - def permissions(self): + def permissions(self) -> List["PermissionsRule"]: if self._permissions is None: error = "View item must be populated with permissions first." raise UnpopulatedPropertyError(error) return self._permissions() - def _set_permissions(self, permissions): + def _set_permissions(self, permissions: Callable[[], List["PermissionsRule"]]) -> None: self._permissions = permissions @classmethod - def from_response(cls, resp, ns, workbook_id=""): - return cls.from_xml_element(ET.fromstring(resp), ns, workbook_id) + def from_response(cls, resp, ns, workbook_id="") -> List["ViewItem"]: + return cls.from_xml_element(fromstring(resp), ns, workbook_id) @classmethod - def from_xml_element(cls, parsed_response, ns, workbook_id=""): + def from_xml_element(cls, parsed_response, ns, workbook_id="") -> List["ViewItem"]: all_view_items = list() all_view_xml = parsed_response.findall(".//t:view", namespaces=ns) for view_xml in all_view_xml: diff --git a/tableauserverclient/models/webhook_item.py b/tableauserverclient/models/webhook_item.py index 5fc5c5749..e4d5e4aa0 100644 --- a/tableauserverclient/models/webhook_item.py +++ b/tableauserverclient/models/webhook_item.py @@ -1,7 +1,8 @@ -import xml.etree.ElementTree as ET - import re +import xml.etree.ElementTree as ET +from typing import List, Optional, Tuple, Type +from defusedxml.ElementTree import fromstring NAMESPACE_RE = re.compile(r"^{.*}") @@ -14,11 +15,11 @@ def _parse_event(events): class WebhookItem(object): def __init__(self): - self._id = None - self.name = None - self.url = None - self._event = None - self.owner_id = None + self._id: Optional[str] = None + self.name: Optional[str] = None + self.url: Optional[str] = None + self._event: Optional[str] = None + self.owner_id: Optional[str] = None def _set_values(self, id, name, url, event, owner_id): if id is not None: @@ -33,23 +34,23 @@ def _set_values(self, id, name, url, event, owner_id): self.owner_id = owner_id @property - def id(self): + def id(self) -> Optional[str]: return self._id @property - def event(self): + def event(self) -> Optional[str]: if self._event: return self._event.replace("webhook-source-event-", "") return None @event.setter - def event(self, value): + def event(self, value: str) -> None: self._event = "webhook-source-event-{}".format(value) @classmethod - def from_response(cls, resp, ns): + def from_response(cls: Type["WebhookItem"], resp: bytes, ns) -> List["WebhookItem"]: all_webhooks_items = list() - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) all_webhooks_xml = parsed_response.findall(".//t:webhook", namespaces=ns) for webhook_xml in all_webhooks_xml: values = cls._parse_element(webhook_xml, ns) @@ -60,7 +61,7 @@ def from_response(cls, resp, ns): return all_webhooks_items @staticmethod - def _parse_element(webhook_xml, ns): + def _parse_element(webhook_xml: ET.Element, ns) -> Tuple: id = webhook_xml.get("id", None) name = webhook_xml.get("name", None) @@ -80,5 +81,5 @@ def _parse_element(webhook_xml, ns): return id, name, url, event, owner_id - def __repr__(self): + def __repr__(self) -> str: return "".format(self.id, self.name, self.url, self.event) diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 9c7e2022e..949970ced 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -1,5 +1,12 @@ +import copy +import uuid import xml.etree.ElementTree as ET +from typing import Callable, Dict, List, Optional, Set, TYPE_CHECKING + +from defusedxml.ElementTree import fromstring + from .exceptions import UnpopulatedPropertyError +from .permissions_item import PermissionsRule from .property_decorators import ( property_not_nullable, property_is_boolean, @@ -7,32 +14,46 @@ ) from .tag_item import TagItem from .view_item import ViewItem -from .permissions_item import PermissionsRule from ..datetime_helpers import parse_datetime -import copy -import uuid + + +if TYPE_CHECKING: + from .connection_item import ConnectionItem + from .permissions_item import PermissionsRule + import datetime + from .revision_item import RevisionItem + +from typing import Dict, List, Optional, Set, TYPE_CHECKING, Union + +if TYPE_CHECKING: + from .connection_item import ConnectionItem + from .permissions_item import PermissionsRule + import datetime class WorkbookItem(object): - def __init__(self, project_id, name=None, show_tabs=False): + def __init__(self, project_id: str, name: str = None, show_tabs: bool = False) -> None: self._connections = None self._content_url = None self._webpage_url = None self._created_at = None - self._id = None - self._initial_tags = set() + self._id: Optional[str] = None + self._initial_tags: set = set() self._pdf = None + self._powerpoint = None self._preview_image = None self._project_name = None + self._revisions = None self._size = None self._updated_at = None self._views = None self.name = name self._description = None - self.owner_id = None + self.owner_id: Optional[str] = None self.project_id = project_id self.show_tabs = show_tabs - self.tags = set() + self.hidden_views: Optional[List[str]] = None + self.tags: Set[str] = set() self.data_acceleration_config = { "acceleration_enabled": None, "accelerate_now": None, @@ -41,74 +62,83 @@ def __init__(self, project_id, name=None, show_tabs=False): } self._permissions = None + return None + @property - def connections(self): + def connections(self) -> List["ConnectionItem"]: if self._connections is None: error = "Workbook item must be populated with connections first." raise UnpopulatedPropertyError(error) return self._connections() @property - def permissions(self): + def permissions(self) -> List["PermissionsRule"]: if self._permissions is None: error = "Workbook item must be populated with permissions first." raise UnpopulatedPropertyError(error) return self._permissions() @property - def content_url(self): + def content_url(self) -> Optional[str]: return self._content_url @property - def webpage_url(self): + def webpage_url(self) -> Optional[str]: return self._webpage_url @property - def created_at(self): + def created_at(self) -> Optional["datetime.datetime"]: return self._created_at @property - def description(self): + def description(self) -> Optional[str]: return self._description @property - def id(self): + def id(self) -> Optional[str]: return self._id @property - def pdf(self): + def powerpoint(self) -> bytes: + if self._powerpoint is None: + error = "Workbook item must be populated with its powerpoint first." + raise UnpopulatedPropertyError(error) + return self._powerpoint() + + @property + def pdf(self) -> bytes: if self._pdf is None: error = "Workbook item must be populated with its pdf first." raise UnpopulatedPropertyError(error) return self._pdf() @property - def preview_image(self): + def preview_image(self) -> bytes: if self._preview_image is None: error = "Workbook item must be populated with its preview image first." raise UnpopulatedPropertyError(error) return self._preview_image() @property - def project_id(self): + def project_id(self) -> Optional[str]: return self._project_id @project_id.setter @property_not_nullable - def project_id(self, value): + def project_id(self, value: str): self._project_id = value @property - def project_name(self): + def project_name(self) -> Optional[str]: return self._project_name @property - def show_tabs(self): + def show_tabs(self) -> bool: return self._show_tabs @show_tabs.setter @property_is_boolean - def show_tabs(self, value): + def show_tabs(self, value: bool): self._show_tabs = value @property @@ -116,11 +146,11 @@ def size(self): return self._size @property - def updated_at(self): + def updated_at(self) -> Optional["datetime.datetime"]: return self._updated_at @property - def views(self): + def views(self) -> List[ViewItem]: # Views can be set in an initial workbook response OR by a call # to Server. Without getting too fancy, I think we can rely on # returning a list from the response, until they call @@ -145,24 +175,37 @@ def data_acceleration_config(self): def data_acceleration_config(self, value): self._data_acceleration_config = value + @property + def revisions(self) -> List["RevisionItem"]: + if self._revisions is None: + error = "Workbook item must be populated with revisions first." + raise UnpopulatedPropertyError(error) + return self._revisions() + def _set_connections(self, connections): self._connections = connections def _set_permissions(self, permissions): self._permissions = permissions - def _set_views(self, views): + def _set_views(self, views: Callable[[], List[ViewItem]]) -> None: self._views = views - def _set_pdf(self, pdf): + def _set_pdf(self, pdf: Callable[[], bytes]) -> None: self._pdf = pdf - def _set_preview_image(self, preview_image): + def _set_powerpoint(self, pptx: Callable[[], bytes]) -> None: + self._powerpoint = pptx + + def _set_preview_image(self, preview_image: Callable[[], bytes]) -> None: self._preview_image = preview_image + def _set_revisions(self, revisions): + self._revisions = revisions + def _parse_common_tags(self, workbook_xml, ns): if not isinstance(workbook_xml, ET.Element): - workbook_xml = ET.fromstring(workbook_xml).find(".//t:workbook", namespaces=ns) + workbook_xml = fromstring(workbook_xml).find(".//t:workbook", namespaces=ns) if workbook_xml is not None: ( _, @@ -253,9 +296,9 @@ def _set_values( self.data_acceleration_config = data_acceleration_config @classmethod - def from_response(cls, resp, ns): + def from_response(cls, resp: str, ns: Dict[str, str]) -> List["WorkbookItem"]: all_workbook_items = list() - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) all_workbook_xml = parsed_response.findall(".//t:workbook", namespaces=ns) for workbook_xml in all_workbook_xml: ( @@ -394,5 +437,5 @@ def parse_data_acceleration_config(data_acceleration_elem): # Used to convert string represented boolean to a boolean type -def string_to_bool(s): +def string_to_bool(s: str) -> bool: return s.lower() == "true" diff --git a/tableauserverclient/namespace.py b/tableauserverclient/namespace.py index 986a02fb3..d225ecff6 100644 --- a/tableauserverclient/namespace.py +++ b/tableauserverclient/namespace.py @@ -1,6 +1,7 @@ -from xml.etree import ElementTree as ET import re +from defusedxml.ElementTree import fromstring + OLD_NAMESPACE = "http://tableausoftware.com/api" NEW_NAMESPACE = "http://tableau.com/api" NAMESPACE_RE = re.compile(r"\{(.*?)\}") @@ -25,7 +26,7 @@ def detect(self, xml): if not xml.startswith(b" None: super(DataAlerts, self).__init__(parent_srv) @property - def baseurl(self): + def baseurl(self) -> str: return "{0}/sites/{1}/dataAlerts".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="3.2") - def get(self, req_options=None): + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[DataAlertItem], PaginationItem]: logger.info("Querying all dataAlerts on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -29,7 +33,7 @@ def get(self, req_options=None): # Get 1 dataAlert @api(version="3.2") - def get_by_id(self, dataAlert_id): + def get_by_id(self, dataAlert_id: str) -> DataAlertItem: if not dataAlert_id: error = "dataAlert ID undefined." raise ValueError(error) @@ -39,8 +43,13 @@ def get_by_id(self, dataAlert_id): return DataAlertItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="3.2") - def delete(self, dataAlert): - dataAlert_id = getattr(dataAlert, "id", dataAlert) + def delete(self, dataAlert: Union[DataAlertItem, str]) -> None: + if isinstance(dataAlert, DataAlertItem): + dataAlert_id = dataAlert.id + elif isinstance(dataAlert, str): + dataAlert_id = dataAlert + else: + raise TypeError("dataAlert should be a DataAlertItem or a string of an id.") if not dataAlert_id: error = "Dataalert ID undefined." raise ValueError(error) @@ -50,9 +59,19 @@ def delete(self, dataAlert): logger.info("Deleted single dataAlert (ID: {0})".format(dataAlert_id)) @api(version="3.2") - def delete_user_from_alert(self, dataAlert, user): - dataAlert_id = getattr(dataAlert, "id", dataAlert) - user_id = getattr(user, "id", user) + def delete_user_from_alert(self, dataAlert: Union[DataAlertItem, str], user: Union[UserItem, str]) -> None: + if isinstance(dataAlert, DataAlertItem): + dataAlert_id = dataAlert.id + elif isinstance(dataAlert, str): + dataAlert_id = dataAlert + else: + raise TypeError("dataAlert should be a DataAlertItem or a string of an id.") + if isinstance(user, UserItem): + user_id = user.id + elif isinstance(user, str): + user_id = user + else: + raise TypeError("user should be a UserItem or a string of an id.") if not dataAlert_id: error = "Dataalert ID undefined." raise ValueError(error) @@ -65,11 +84,16 @@ def delete_user_from_alert(self, dataAlert, user): logger.info("Deleted User (ID {0}) from dataAlert (ID: {1})".format(user_id, dataAlert_id)) @api(version="3.2") - def add_user_to_alert(self, dataAlert_item, user): + def add_user_to_alert(self, dataAlert_item: DataAlertItem, user: Union[UserItem, str]) -> UserItem: + if isinstance(user, UserItem): + user_id = user.id + elif isinstance(user, str): + user_id = user + else: + raise TypeError("user should be a UserItem or a string of an id.") if not dataAlert_item.id: error = "Dataalert item missing ID." raise MissingRequiredFieldError(error) - user_id = getattr(user, "id", user) if not user_id: error = "User ID undefined." raise ValueError(error) @@ -77,11 +101,11 @@ def add_user_to_alert(self, dataAlert_item, user): update_req = RequestFactory.DataAlert.add_user_to_alert(dataAlert_item, user_id) server_response = self.post_request(url, update_req) logger.info("Added user (ID {0}) to dataAlert item (ID: {1})".format(user_id, dataAlert_item.id)) - user = UserItem.from_response(server_response.content, self.parent_srv.namespace)[0] - return user + added_user = UserItem.from_response(server_response.content, self.parent_srv.namespace)[0] + return added_user @api(version="3.2") - def update(self, dataAlert_item): + def update(self, dataAlert_item: DataAlertItem) -> DataAlertItem: if not dataAlert_item.id: error = "Dataalert item missing ID." raise MissingRequiredFieldError(error) diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index 50826ee0b..255b7b7a3 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -1,13 +1,12 @@ +import logging + +from .default_permissions_endpoint import _DefaultPermissionsEndpoint +from .dqw_endpoint import _DataQualityWarningEndpoint from .endpoint import api, Endpoint from .exceptions import MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint -from .default_permissions_endpoint import _DefaultPermissionsEndpoint -from .dqw_endpoint import _DataQualityWarningEndpoint - from .. import RequestFactory, DatabaseItem, TableItem, PaginationItem, Permission -import logging - logger = logging.getLogger("tableau.endpoint.databases") diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 18a2f318c..cb5600938 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -1,9 +1,27 @@ +import cgi +import copy +import io +import json +import logging +import os +from contextlib import closing +from pathlib import Path +from typing import ( + List, + Mapping, + Optional, + Sequence, + Tuple, + TYPE_CHECKING, + Union, +) + +from .dqw_endpoint import _DataQualityWarningEndpoint from .endpoint import QuerysetEndpoint, api, parameter_added_in from .exceptions import InternalServerError, MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint -from .dqw_endpoint import _DataQualityWarningEndpoint from .resource_tagger import _ResourceTagger -from .. import RequestFactory, DatasourceItem, PaginationItem, ConnectionItem +from .. import RequestFactory, DatasourceItem, PaginationItem, ConnectionItem, RequestOptions from ..query import QuerySet from ...filesys_helpers import ( to_filename, @@ -11,14 +29,24 @@ get_file_type, get_file_object_size, ) +from ...models import ConnectionCredentials, RevisionItem from ...models.job_item import JobItem +from ...models import ConnectionCredentials + +io_types = (io.BytesIO, io.BufferedReader) + +from pathlib import Path +from typing import ( + List, + Mapping, + Optional, + Sequence, + Tuple, + TYPE_CHECKING, + Union, +) -import os -import logging -import copy -import cgi -from contextlib import closing -import json +io_types = (io.BytesIO, io.BufferedReader) # The maximum size of a file that can be published in a single request is 64MB FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB @@ -27,21 +55,32 @@ logger = logging.getLogger("tableau.endpoint.datasources") +if TYPE_CHECKING: + from ..server import Server + from ...models import PermissionsRule + from .schedules_endpoint import AddResponse + +FilePath = Union[str, os.PathLike] +FileObject = Union[io.BufferedReader, io.BytesIO] +PathOrFile = Union[FilePath, FileObject] + class Datasources(QuerysetEndpoint): - def __init__(self, parent_srv): + def __init__(self, parent_srv: "Server") -> None: super(Datasources, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "datasource") + return None + @property - def baseurl(self): + def baseurl(self) -> str: return "{0}/sites/{1}/datasources".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Get all datasources @api(version="2.0") - def get(self, req_options=None): + def get(self, req_options: RequestOptions = None) -> Tuple[List[DatasourceItem], PaginationItem]: logger.info("Querying all datasources on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -51,7 +90,7 @@ def get(self, req_options=None): # Get 1 datasource by id @api(version="2.0") - def get_by_id(self, datasource_id): + def get_by_id(self, datasource_id: str) -> DatasourceItem: if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) @@ -62,7 +101,7 @@ def get_by_id(self, datasource_id): # Populate datasource item's connections @api(version="2.0") - def populate_connections(self, datasource_item): + def populate_connections(self, datasource_item: DatasourceItem) -> None: if not datasource_item.id: error = "Datasource item missing ID. Datasource must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -81,7 +120,7 @@ def _get_datasource_connections(self, datasource_item, req_options=None): # Delete 1 datasource by id @api(version="2.0") - def delete(self, datasource_id): + def delete(self, datasource_id: str) -> None: if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) @@ -93,7 +132,13 @@ def delete(self, datasource_id): @api(version="2.0") @parameter_added_in(no_extract="2.5") @parameter_added_in(include_extract="2.5") - def download(self, datasource_id, filepath=None, include_extract=True, no_extract=None): + def download( + self, + datasource_id: str, + filepath: FilePath = None, + include_extract: bool = True, + no_extract: Optional[bool] = None, + ) -> str: if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) @@ -126,7 +171,7 @@ def download(self, datasource_id, filepath=None, include_extract=True, no_extrac # Update datasource @api(version="2.0") - def update(self, datasource_item): + def update(self, datasource_item: DatasourceItem) -> DatasourceItem: if not datasource_item.id: error = "Datasource item missing ID. Datasource must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -143,7 +188,7 @@ def update(self, datasource_item): # Update datasource connections @api(version="2.3") - def update_connection(self, datasource_item, connection_item): + def update_connection(self, datasource_item: DatasourceItem, connection_item: ConnectionItem) -> ConnectionItem: url = "{0}/{1}/connections/{2}".format(self.baseurl, datasource_item.id, connection_item.id) update_req = RequestFactory.Connection.update_req(connection_item) @@ -156,7 +201,7 @@ def update_connection(self, datasource_item, connection_item): return connection @api(version="2.8") - def refresh(self, datasource_item): + def refresh(self, datasource_item: DatasourceItem) -> JobItem: id_ = getattr(datasource_item, "id", datasource_item) url = "{0}/{1}/refresh".format(self.baseurl, id_) empty_req = RequestFactory.Empty.empty_req() @@ -165,7 +210,7 @@ def refresh(self, datasource_item): return new_job @api(version="3.5") - def create_extract(self, datasource_item, encrypt=False): + def create_extract(self, datasource_item: DatasourceItem, encrypt: bool = False) -> JobItem: id_ = getattr(datasource_item, "id", datasource_item) url = "{0}/{1}/createExtract?encrypt={2}".format(self.baseurl, id_, encrypt) empty_req = RequestFactory.Empty.empty_req() @@ -174,7 +219,7 @@ def create_extract(self, datasource_item, encrypt=False): return new_job @api(version="3.5") - def delete_extract(self, datasource_item): + def delete_extract(self, datasource_item: DatasourceItem) -> None: id_ = getattr(datasource_item, "id", datasource_item) url = "{0}/{1}/deleteExtract".format(self.baseurl, id_) empty_req = RequestFactory.Empty.empty_req() @@ -186,16 +231,15 @@ def delete_extract(self, datasource_item): @parameter_added_in(as_job="3.0") def publish( self, - datasource_item, - file, - mode, - connection_credentials=None, - connections=None, - as_job=False, - ): - - try: - + datasource_item: DatasourceItem, + file: PathOrFile, + mode: str, + connection_credentials: ConnectionCredentials = None, + connections: Sequence[ConnectionItem] = None, + as_job: bool = False, + ) -> Union[DatasourceItem, JobItem]: + + if isinstance(file, (os.PathLike, str)): if not os.path.isfile(file): error = "File path does not lead to an existing file." raise IOError(error) @@ -211,7 +255,7 @@ def publish( error = "Only {} files can be published as datasources.".format(", ".join(ALLOWED_FILE_EXTENSIONS)) raise ValueError(error) - except TypeError: + elif isinstance(file, io_types): if not datasource_item.name: error = "Datasource item must have a name when passing a file object" @@ -229,6 +273,9 @@ def publish( filename = "{}.{}".format(datasource_item.name, file_extension) file_size = get_file_object_size(file) + else: + raise TypeError("file should be a filepath or file object.") + if not mode or not hasattr(self.parent_srv.PublishMode, mode): error = "Invalid mode defined." raise ValueError(error) @@ -252,11 +299,13 @@ def publish( else: logger.info("Publishing {0} to server".format(filename)) - try: + if isinstance(file, (Path, str)): with open(file, "rb") as f: file_contents = f.read() - except TypeError: + elif isinstance(file, io_types): file_contents = file.read() + else: + raise TypeError("file should be a filepath or file object.") xml_request, content_type = RequestFactory.Datasource.publish_req( datasource_item, @@ -284,7 +333,14 @@ def publish( return new_datasource @api(version="3.13") - def update_hyper_data(self, datasource_or_connection_item, *, request_id, actions, payload = None): + def update_hyper_data( + self, + datasource_or_connection_item: Union[DatasourceItem, ConnectionItem, str], + *, + request_id: str, + actions: Sequence[Mapping], + payload: Optional[FilePath] = None + ) -> JobItem: if isinstance(datasource_or_connection_item, DatasourceItem): datasource_id = datasource_or_connection_item.id url = "{0}/{1}/data".format(self.baseurl, datasource_id) @@ -312,7 +368,7 @@ def update_hyper_data(self, datasource_or_connection_item, *, request_id, action return new_job @api(version="2.0") - def populate_permissions(self, item): + def populate_permissions(self, item: DatasourceItem) -> None: self._permissions.populate(item) @api(version="2.0") @@ -327,11 +383,11 @@ def update_permission(self, item, permission_item): self._permissions.update(item, permission_item) @api(version="2.0") - def update_permissions(self, item, permission_item): + def update_permissions(self, item: DatasourceItem, permission_item: List["PermissionsRule"]) -> None: self._permissions.update(item, permission_item) @api(version="2.0") - def delete_permission(self, item, capability_item): + def delete_permission(self, item: DatasourceItem, capability_item: "PermissionsRule") -> None: self._permissions.delete(item, capability_item) @api(version="3.5") @@ -349,3 +405,83 @@ def add_dqw(self, item, warning): @api(version="3.5") def delete_dqw(self, item): self._data_quality_warnings.clear(item) + + # Populate datasource item's revisions + @api(version="2.3") + def populate_revisions(self, datasource_item: DatasourceItem) -> None: + if not datasource_item.id: + error = "Datasource item missing ID. Datasource must be retrieved from server first." + raise MissingRequiredFieldError(error) + + def revisions_fetcher(): + return self._get_datasource_revisions(datasource_item) + + datasource_item._set_revisions(revisions_fetcher) + logger.info("Populated revisions for datasource (ID: {0})".format(datasource_item.id)) + + def _get_datasource_revisions( + self, datasource_item: DatasourceItem, req_options: Optional["RequestOptions"] = None + ) -> List[RevisionItem]: + url = "{0}/{1}/revisions".format(self.baseurl, datasource_item.id) + server_response = self.get_request(url, req_options) + revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, datasource_item) + return revisions + + # Download 1 datasource revision by revision number + @api(version="2.3") + def download_revision( + self, + datasource_id: str, + revision_number: str, + filepath: Optional[PathOrFile] = None, + include_extract: bool = True, + no_extract: Optional[bool] = None, + ) -> str: + if not datasource_id: + error = "Datasource ID undefined." + raise ValueError(error) + 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" + + with closing(self.get_request(url, parameters={"stream": True})) as server_response: + _, params = cgi.parse_header(server_response.headers["Content-Disposition"]) + filename = to_filename(os.path.basename(params["filename"])) + + download_path = make_download_path(filepath, filename) + + with open(download_path, "wb") as f: + for chunk in server_response.iter_content(1024): # 1KB + f.write(chunk) + + logger.info( + "Downloaded datasource revision {0} to {1} (ID: {2})".format(revision_number, download_path, datasource_id) + ) + return os.path.abspath(download_path) + + @api(version="2.3") + def delete_revision(self, datasource_id: str, revision_number: str) -> None: + if datasource_id is None or revision_number is None: + raise ValueError + url = "/".join([self.baseurl, datasource_id, "revisions", revision_number]) + + self.delete_request(url) + logger.info( + "Deleted single datasource revision (ID: {0}) (Revision: {1})".format(datasource_id, revision_number) + ) + + # a convenience method + @api(version="2.8") + def schedule_extract_refresh( + self, schedule_id: str, item: DatasourceItem + ) -> List["AddResponse"]: # actually should return a task + return self.parent_srv.schedules.add_to_schedule(schedule_id, datasource=item) diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py index 1cfa41733..6e54d02c7 100644 --- a/tableauserverclient/server/endpoint/default_permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py @@ -1,13 +1,27 @@ import logging +from .endpoint import Endpoint +from .exceptions import MissingRequiredFieldError from .. import RequestFactory from ...models import PermissionsRule -from .endpoint import Endpoint -from .exceptions import MissingRequiredFieldError +logger = logging.getLogger(__name__) +from typing import TYPE_CHECKING, Callable, List, Optional, Sequence, Union -logger = logging.getLogger(__name__) +if TYPE_CHECKING: + from ...models import ( + DatasourceItem, + FlowItem, + ProjectItem, + ViewItem, + WorkbookItem, + ) + + from ..server import Server + from ..request_options import RequestOptions + + TableauItem = Union[DatasourceItem, FlowItem, ProjectItem, ViewItem, WorkbookItem] class _DefaultPermissionsEndpoint(Endpoint): @@ -19,7 +33,7 @@ class _DefaultPermissionsEndpoint(Endpoint): has these supported endpoints """ - def __init__(self, parent_srv, owner_baseurl): + def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> None: super(_DefaultPermissionsEndpoint, self).__init__(parent_srv) # owner_baseurl is the baseurl of the parent. The MUST be a lambda @@ -27,7 +41,9 @@ def __init__(self, parent_srv, owner_baseurl): # populated without, we will get a sign-in error self.owner_baseurl = owner_baseurl - def update_default_permissions(self, resource, permissions, content_type): + def update_default_permissions( + self, resource: "TableauItem", permissions: Sequence[PermissionsRule], content_type: str + ) -> List[PermissionsRule]: url = "{0}/{1}/default-permissions/{2}".format(self.owner_baseurl(), resource.id, content_type + "s") update_req = RequestFactory.Permission.add_req(permissions) response = self.put_request(url, update_req) @@ -36,7 +52,7 @@ def update_default_permissions(self, resource, permissions, content_type): return permissions - def delete_default_permission(self, resource, rule, content_type): + def delete_default_permission(self, resource: "TableauItem", rule: PermissionsRule, content_type: str) -> None: for capability, mode in rule.capabilities.items(): # Made readability better but line is too long, will make this look better url = ( @@ -60,18 +76,20 @@ def delete_default_permission(self, resource, rule, content_type): "Deleted permission for {0} {1} item {2}".format(rule.grantee.tag_name, rule.grantee.id, resource.id) ) - def populate_default_permissions(self, item, content_type): + def populate_default_permissions(self, item: "ProjectItem", content_type: str) -> None: if not item.id: error = "Server item is missing ID. Item must be retrieved from server first." raise MissingRequiredFieldError(error) - def permission_fetcher(): + def permission_fetcher() -> List[PermissionsRule]: return self._get_default_permissions(item, content_type) item._set_default_permissions(permission_fetcher, content_type) logger.info("Populated {0} permissions for item (ID: {1})".format(item.id, content_type)) - def _get_default_permissions(self, item, content_type, req_options=None): + def _get_default_permissions( + self, item: "TableauItem", content_type: str, req_options: Optional["RequestOptions"] = None + ) -> List[PermissionsRule]: url = "{0}/{1}/default-permissions/{2}".format(self.owner_baseurl(), item.id, content_type + "s") server_response = self.get_request(url, req_options) permissions = PermissionsRule.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/endpoint/dqw_endpoint.py b/tableauserverclient/server/endpoint/dqw_endpoint.py index e19ca7d90..ff1637721 100644 --- a/tableauserverclient/server/endpoint/dqw_endpoint.py +++ b/tableauserverclient/server/endpoint/dqw_endpoint.py @@ -1,10 +1,8 @@ import logging -from .. import RequestFactory, DQWItem - from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError - +from .. import RequestFactory, DQWItem logger = logging.getLogger(__name__) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 3372afdf1..8fdb74751 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,23 +1,23 @@ +import logging +from distutils.version import LooseVersion as Version +from functools import wraps +from xml.etree.ElementTree import ParseError + from .exceptions import ( ServerResponseError, InternalServerError, NonXMLResponseError, EndpointUnavailableError, ) -from functools import wraps -from xml.etree.ElementTree import ParseError from ..query import QuerySet -import logging - -try: - from distutils2.version import NormalizedVersion as Version -except ImportError: - from packaging.version import Version logger = logging.getLogger("tableau.endpoint") Success_codes = [200, 201, 202, 204] +XML_CONTENT_TYPE = "text/xml" +JSON_CONTENT_TYPE = "application/json" + class Endpoint(object): def __init__(self, parent_srv): @@ -62,9 +62,9 @@ def _make_request( if content is not None: parameters["data"] = content - logger.debug(u"request {}, url: {}".format(method.__name__, url)) + logger.debug("request {}, url: {}".format(method.__name__, url)) if content: - logger.debug(u"request content: {}".format(content[:1000])) + logger.debug("request content: {}".format(content[:1000])) server_response = method(url, **parameters) self.parent_srv._namespace.detect(server_response.content) @@ -74,9 +74,7 @@ def _make_request( # so that we do not attempt to log bytes and other binary data. if len(server_response.content) > 0 and server_response.encoding: logger.debug( - u"Server response from {0}:\n\t{1}".format( - url, server_response.content.decode(server_response.encoding) - ) + "Server response from {0}:\n\t{1}".format(url, server_response.content.decode(server_response.encoding)) ) return server_response @@ -230,7 +228,9 @@ def all(self, *args, **kwargs): return queryset @api(version="2.0") - def filter(self, *args, **kwargs): + def filter(self, *_, **kwargs): + if _: + raise RuntimeError("Only keyword arguments accepted.") queryset = QuerySet(self).filter(**kwargs) return queryset diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 48dcaf4c8..34de00dd0 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -1,4 +1,4 @@ -import xml.etree.ElementTree as ET +from defusedxml.ElementTree import fromstring class ServerResponseError(Exception): @@ -14,7 +14,7 @@ def __str__(self): @classmethod def from_response(cls, resp, ns): # Check elements exist before .text - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) error_response = cls( parsed_response.find("t:error", namespaces=ns).get("code", ""), parsed_response.find(".//t:summary", namespaces=ns).text, @@ -70,13 +70,15 @@ class JobFailedException(Exception): def __init__(self, job): self.notes = job.notes self.job = job - + def __str__(self): return f"Job {self.job.id} failed with notes {self.notes}" class JobCancelledException(JobFailedException): pass + + class FlowRunFailedException(Exception): def __init__(self, flow_run): self.background_job_id = flow_run.background_job_id @@ -87,4 +89,4 @@ def __str__(self): class FlowRunCancelledException(FlowRunFailedException): - pass + pass diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index 459d852e6..19199c5a0 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -1,23 +1,26 @@ +import logging + from .endpoint import Endpoint, api -from .exceptions import MissingRequiredFieldError from .. import RequestFactory from ...models import FavoriteItem -from ..pager import Pager -import xml.etree.ElementTree as ET -import logging -import copy logger = logging.getLogger("tableau.endpoint.favorites") +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from ...models import DatasourceItem, FlowItem, ProjectItem, UserItem, ViewItem, WorkbookItem + from ..request_options import RequestOptions + class Favorites(Endpoint): @property - def baseurl(self): + def baseurl(self) -> str: return "{0}/sites/{1}/favorites".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Gets all favorites @api(version="2.5") - def get(self, user_item, req_options=None): + def get(self, user_item: "UserItem", req_options: Optional["RequestOptions"] = None) -> None: logger.info("Querying all favorites for user {0}".format(user_item.name)) url = "{0}/{1}".format(self.baseurl, user_item.id) server_response = self.get_request(url, req_options) @@ -25,53 +28,66 @@ def get(self, user_item, req_options=None): user_item._favorites = FavoriteItem.from_response(server_response.content, self.parent_srv.namespace) @api(version="2.0") - def add_favorite_workbook(self, user_item, workbook_item): + def add_favorite_workbook(self, user_item: "UserItem", workbook_item: "WorkbookItem") -> None: url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_workbook_req(workbook_item.id, workbook_item.name) server_response = self.put_request(url, add_req) logger.info("Favorited {0} for user (ID: {1})".format(workbook_item.name, user_item.id)) @api(version="2.0") - def add_favorite_view(self, user_item, view_item): + def add_favorite_view(self, user_item: "UserItem", view_item: "ViewItem") -> None: url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_view_req(view_item.id, view_item.name) server_response = self.put_request(url, add_req) logger.info("Favorited {0} for user (ID: {1})".format(view_item.name, user_item.id)) @api(version="2.3") - def add_favorite_datasource(self, user_item, datasource_item): + def add_favorite_datasource(self, user_item: "UserItem", datasource_item: "DatasourceItem") -> None: url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_datasource_req(datasource_item.id, datasource_item.name) server_response = self.put_request(url, add_req) logger.info("Favorited {0} for user (ID: {1})".format(datasource_item.name, user_item.id)) @api(version="3.1") - def add_favorite_project(self, user_item, project_item): + def add_favorite_project(self, user_item: "UserItem", project_item: "ProjectItem") -> None: url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_project_req(project_item.id, project_item.name) server_response = self.put_request(url, add_req) logger.info("Favorited {0} for user (ID: {1})".format(project_item.name, user_item.id)) + @api(version="3.3") + def add_favorite_flow(self, user_item: "UserItem", flow_item: "FlowItem") -> None: + url = "{0}/{1}".format(self.baseurl, user_item.id) + add_req = RequestFactory.Favorite.add_flow_req(flow_item.id, flow_item.name) + server_response = self.put_request(url, add_req) + logger.info("Favorited {0} for user (ID: {1})".format(flow_item.name, user_item.id)) + @api(version="2.0") - def delete_favorite_workbook(self, user_item, workbook_item): + def delete_favorite_workbook(self, user_item: "UserItem", workbook_item: "WorkbookItem") -> None: url = "{0}/{1}/workbooks/{2}".format(self.baseurl, user_item.id, workbook_item.id) logger.info("Removing favorite {0} for user (ID: {1})".format(workbook_item.id, user_item.id)) self.delete_request(url) @api(version="2.0") - def delete_favorite_view(self, user_item, view_item): + def delete_favorite_view(self, user_item: "UserItem", view_item: "ViewItem") -> None: url = "{0}/{1}/views/{2}".format(self.baseurl, user_item.id, view_item.id) logger.info("Removing favorite {0} for user (ID: {1})".format(view_item.id, user_item.id)) self.delete_request(url) @api(version="2.3") - def delete_favorite_datasource(self, user_item, datasource_item): + def delete_favorite_datasource(self, user_item: "UserItem", datasource_item: "DatasourceItem") -> None: url = "{0}/{1}/datasources/{2}".format(self.baseurl, user_item.id, datasource_item.id) logger.info("Removing favorite {0} for user (ID: {1})".format(datasource_item.id, user_item.id)) self.delete_request(url) @api(version="3.1") - def delete_favorite_project(self, user_item, project_item): + def delete_favorite_project(self, user_item: "UserItem", project_item: "ProjectItem") -> None: url = "{0}/{1}/projects/{2}".format(self.baseurl, user_item.id, project_item.id) logger.info("Removing favorite {0} for user (ID: {1})".format(project_item.id, user_item.id)) self.delete_request(url) + + @api(version="3.3") + def delete_favorite_flow(self, user_item: "UserItem", flow_item: "FlowItem") -> None: + url = "{0}/{1}/projects/{2}".format(self.baseurl, user_item.id, flow_item.id) + logger.info("Removing favorite {0} for user (ID: {1})".format(flow_item.id, user_item.id)) + self.delete_request(url) diff --git a/tableauserverclient/server/endpoint/fileuploads_endpoint.py b/tableauserverclient/server/endpoint/fileuploads_endpoint.py index b70cffbaa..3df8ee4d5 100644 --- a/tableauserverclient/server/endpoint/fileuploads_endpoint.py +++ b/tableauserverclient/server/endpoint/fileuploads_endpoint.py @@ -1,9 +1,8 @@ -from .exceptions import MissingRequiredFieldError +import logging + from .endpoint import Endpoint, api from .. import RequestFactory from ...models.fileupload_item import FileuploadItem -import os.path -import logging # For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks CHUNK_SIZE = 1024 * 1024 * 5 # 5MB diff --git a/tableauserverclient/server/endpoint/flow_runs_endpoint.py b/tableauserverclient/server/endpoint/flow_runs_endpoint.py index 2ae1973d4..62f910dea 100644 --- a/tableauserverclient/server/endpoint/flow_runs_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_runs_endpoint.py @@ -1,24 +1,30 @@ -from .endpoint import Endpoint, QuerysetEndpoint, api +import logging +from typing import List, Optional, Tuple, TYPE_CHECKING + +from .endpoint import QuerysetEndpoint, api from .exceptions import FlowRunFailedException, FlowRunCancelledException from .. import FlowRunItem, PaginationItem from ...exponential_backoff import ExponentialBackoffTimer -import logging - logger = logging.getLogger("tableau.endpoint.flowruns") +if TYPE_CHECKING: + from ..server import Server + from ..request_options import RequestOptions + class FlowRuns(QuerysetEndpoint): - def __init__(self, parent_srv): + def __init__(self, parent_srv: "Server") -> None: super(FlowRuns, self).__init__(parent_srv) + return None @property - def baseurl(self): + def baseurl(self) -> str: return "{0}/sites/{1}/flows/runs".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Get all flows @api(version="3.10") - def get(self, req_options=None): + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[FlowRunItem], PaginationItem]: logger.info("Querying all flow runs on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -28,7 +34,7 @@ def get(self, req_options=None): # Get 1 flow by id @api(version="3.10") - def get_by_id(self, flow_run_id): + def get_by_id(self, flow_run_id: str) -> FlowRunItem: if not flow_run_id: error = "Flow ID undefined." raise ValueError(error) @@ -37,21 +43,19 @@ def get_by_id(self, flow_run_id): server_response = self.get_request(url) return FlowRunItem.from_response(server_response.content, self.parent_srv.namespace)[0] - # Cancel 1 flow run by id @api(version="3.10") - def cancel(self, flow_run_id): + def cancel(self, flow_run_id: str) -> None: if not flow_run_id: error = "Flow ID undefined." raise ValueError(error) - id_ = getattr(flow_run_id, 'id', flow_run_id) + id_ = getattr(flow_run_id, "id", flow_run_id) url = "{0}/{1}".format(self.baseurl, id_) self.put_request(url) logger.info("Deleted single flow (ID: {0})".format(id_)) - @api(version="3.10") - def wait_for_job(self, flow_run_id, *, timeout=None): + def wait_for_job(self, flow_run_id: str, *, timeout: Optional[int] = None) -> FlowRunItem: if isinstance(flow_run_id, FlowRunItem): flow_run_id = flow_run_id.id assert isinstance(flow_run_id, str) @@ -73,4 +77,4 @@ def wait_for_job(self, flow_run_id, *, timeout=None): elif flow_run.status == "Cancelled": raise FlowRunCancelledException(flow_run) else: - raise AssertionError("Unexpected status in flow_run", flow_run) \ No newline at end of file + raise AssertionError("Unexpected status in flow_run", flow_run) diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index eb2de4ac9..2c54d17c4 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -1,18 +1,19 @@ -from .endpoint import Endpoint, api +import cgi +import copy +import logging +import os +from contextlib import closing +from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union + +from .dqw_endpoint import _DataQualityWarningEndpoint +from .endpoint import Endpoint, QuerysetEndpoint, api from .exceptions import InternalServerError, MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint -from .dqw_endpoint import _DataQualityWarningEndpoint from .resource_tagger import _ResourceTagger from .. import RequestFactory, FlowItem, PaginationItem, ConnectionItem from ...filesys_helpers import to_filename, make_download_path from ...models.job_item import JobItem -import os -import logging -import copy -import cgi -from contextlib import closing - # The maximum size of a file that can be published in a single request is 64MB FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB @@ -20,8 +21,17 @@ logger = logging.getLogger("tableau.endpoint.flows") +if TYPE_CHECKING: + from .. import DQWItem + from ..request_options import RequestOptions + from ...models.permissions_item import PermissionsRule + from .schedules_endpoint import AddResponse + -class Flows(Endpoint): +FilePath = Union[str, os.PathLike] + + +class Flows(QuerysetEndpoint): def __init__(self, parent_srv): super(Flows, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) @@ -29,12 +39,12 @@ def __init__(self, parent_srv): self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "flow") @property - def baseurl(self): + def baseurl(self) -> str: return "{0}/sites/{1}/flows".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Get all flows @api(version="3.3") - def get(self, req_options=None): + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[FlowItem], PaginationItem]: logger.info("Querying all flows on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -44,7 +54,7 @@ def get(self, req_options=None): # Get 1 flow by id @api(version="3.3") - def get_by_id(self, flow_id): + def get_by_id(self, flow_id: str) -> FlowItem: if not flow_id: error = "Flow ID undefined." raise ValueError(error) @@ -55,7 +65,7 @@ def get_by_id(self, flow_id): # Populate flow item's connections @api(version="3.3") - def populate_connections(self, flow_item): + def populate_connections(self, flow_item: FlowItem) -> None: if not flow_item.id: error = "Flow item missing ID. Flow must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -66,7 +76,7 @@ def connections_fetcher(): flow_item._set_connections(connections_fetcher) logger.info("Populated connections for flow (ID: {0})".format(flow_item.id)) - def _get_flow_connections(self, flow_item, req_options=None): + def _get_flow_connections(self, flow_item, req_options: Optional["RequestOptions"] = None) -> List[ConnectionItem]: url = "{0}/{1}/connections".format(self.baseurl, flow_item.id) server_response = self.get_request(url, req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) @@ -74,7 +84,7 @@ def _get_flow_connections(self, flow_item, req_options=None): # Delete 1 flow by id @api(version="3.3") - def delete(self, flow_id): + def delete(self, flow_id: str) -> None: if not flow_id: error = "Flow ID undefined." raise ValueError(error) @@ -84,7 +94,7 @@ def delete(self, flow_id): # Download 1 flow by id @api(version="3.3") - def download(self, flow_id, filepath=None): + def download(self, flow_id: str, filepath: FilePath = None) -> str: if not flow_id: error = "Flow ID undefined." raise ValueError(error) @@ -105,7 +115,7 @@ def download(self, flow_id, filepath=None): # Update flow @api(version="3.3") - def update(self, flow_item): + def update(self, flow_item: FlowItem) -> FlowItem: if not flow_item.id: error = "Flow item missing ID. Flow must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -122,7 +132,7 @@ def update(self, flow_item): # Update flow connections @api(version="3.3") - def update_connection(self, flow_item, connection_item): + def update_connection(self, flow_item: FlowItem, connection_item: ConnectionItem) -> ConnectionItem: url = "{0}/{1}/connections/{2}".format(self.baseurl, flow_item.id, connection_item.id) update_req = RequestFactory.Connection.update_req(connection_item) @@ -133,7 +143,7 @@ def update_connection(self, flow_item, connection_item): return connection @api(version="3.3") - def refresh(self, flow_item): + def refresh(self, flow_item: FlowItem) -> JobItem: url = "{0}/{1}/run".format(self.baseurl, flow_item.id) empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) @@ -142,7 +152,9 @@ def refresh(self, flow_item): # Publish flow @api(version="3.3") - def publish(self, flow_item, file_path, mode, connections=None): + def publish( + self, flow_item: FlowItem, file_path: FilePath, mode: str, connections: Optional[List[ConnectionItem]] = None + ) -> FlowItem: if not os.path.isfile(file_path): error = "File path does not lead to an existing file." raise IOError(error) @@ -189,13 +201,8 @@ def publish(self, flow_item, file_path, mode, connections=None): logger.info("Published {0} (ID: {1})".format(filename, new_flow.id)) return new_flow - server_response = self.post_request(url, xml_request, content_type) - new_flow = FlowItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Published {0} (ID: {1})".format(filename, new_flow.id)) - return new_flow - @api(version="3.3") - def populate_permissions(self, item): + def populate_permissions(self, item: FlowItem) -> None: self._permissions.populate(item) @api(version="3.3") @@ -209,25 +216,32 @@ def update_permission(self, item, permission_item): self._permissions.update(item, permission_item) @api(version="3.3") - def update_permissions(self, item, permission_item): + def update_permissions(self, item: FlowItem, permission_item: Iterable["PermissionsRule"]) -> None: self._permissions.update(item, permission_item) @api(version="3.3") - def delete_permission(self, item, capability_item): + def delete_permission(self, item: FlowItem, capability_item: "PermissionsRule") -> None: self._permissions.delete(item, capability_item) @api(version="3.5") - def populate_dqw(self, item): + def populate_dqw(self, item: FlowItem) -> None: self._data_quality_warnings.populate(item) @api(version="3.5") - def update_dqw(self, item, warning): + def update_dqw(self, item: FlowItem, warning: "DQWItem") -> None: return self._data_quality_warnings.update(item, warning) @api(version="3.5") - def add_dqw(self, item, warning): + def add_dqw(self, item: FlowItem, warning: "DQWItem") -> None: return self._data_quality_warnings.add(item, warning) @api(version="3.5") - def delete_dqw(self, item): + def delete_dqw(self, item: FlowItem) -> None: self._data_quality_warnings.clear(item) + + # a convenience method + @api(version="3.3") + def schedule_flow_run( + self, schedule_id: str, item: FlowItem + ) -> List["AddResponse"]: # actually should return a task + return self.parent_srv.schedules.add_to_schedule(schedule_id, flow=item) diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index b771e56d8..289ccdb11 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -1,21 +1,26 @@ -from .endpoint import Endpoint, api +import logging + +from .endpoint import QuerysetEndpoint, api from .exceptions import MissingRequiredFieldError from .. import RequestFactory, GroupItem, UserItem, PaginationItem, JobItem from ..pager import Pager -import logging - logger = logging.getLogger("tableau.endpoint.groups") +from typing import List, Optional, TYPE_CHECKING, Tuple, Union + +if TYPE_CHECKING: + from ..request_options import RequestOptions + -class Groups(Endpoint): +class Groups(QuerysetEndpoint): @property - def baseurl(self): + def baseurl(self) -> str: return "{0}/sites/{1}/groups".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Gets all groups @api(version="2.0") - def get(self, req_options=None): + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[GroupItem], PaginationItem]: logger.info("Querying all groups on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -25,7 +30,7 @@ def get(self, req_options=None): # Gets all users in a given group @api(version="2.0") - def populate_users(self, group_item, req_options=None): + def populate_users(self, group_item, req_options: Optional["RequestOptions"] = None) -> None: if not group_item.id: error = "Group item missing ID. Group must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -40,7 +45,9 @@ def user_pager(): group_item._set_users(user_pager) - def _get_users_for_group(self, group_item, req_options=None): + def _get_users_for_group( + self, group_item, req_options: Optional["RequestOptions"] = None + ) -> Tuple[List[UserItem], PaginationItem]: url = "{0}/{1}/users".format(self.baseurl, group_item.id) server_response = self.get_request(url, req_options) user_item = UserItem.from_response(server_response.content, self.parent_srv.namespace) @@ -50,7 +57,7 @@ def _get_users_for_group(self, group_item, req_options=None): # Deletes 1 group by id @api(version="2.0") - def delete(self, group_id): + def delete(self, group_id: str) -> None: if not group_id: error = "Group ID undefined." raise ValueError(error) @@ -59,7 +66,9 @@ def delete(self, group_id): logger.info("Deleted single group (ID: {0})".format(group_id)) @api(version="2.0") - def update(self, group_item, default_site_role=None, as_job=False): + def update( + self, group_item: GroupItem, default_site_role: Optional[str] = None, as_job: bool = False + ) -> Union[GroupItem, JobItem]: # (1/8/2021): Deprecated starting v0.15 if default_site_role is not None: import warnings @@ -90,7 +99,7 @@ def update(self, group_item, default_site_role=None, as_job=False): # Create a 'local' Tableau group @api(version="2.0") - def create(self, group_item): + def create(self, group_item: GroupItem) -> GroupItem: url = self.baseurl create_req = RequestFactory.Group.create_local_req(group_item) server_response = self.post_request(url, create_req) @@ -98,7 +107,7 @@ def create(self, group_item): # Create a group based on Active Directory @api(version="2.0") - def create_AD_group(self, group_item, asJob=False): + def create_AD_group(self, group_item: GroupItem, asJob: bool = False) -> Union[GroupItem, JobItem]: asJobparameter = "?asJob=true" if asJob else "" url = self.baseurl + asJobparameter create_req = RequestFactory.Group.create_ad_req(group_item) @@ -110,7 +119,7 @@ def create_AD_group(self, group_item, asJob=False): # Removes 1 user from 1 group @api(version="2.0") - def remove_user(self, group_item, user_id): + def remove_user(self, group_item: GroupItem, user_id: str) -> None: if not group_item.id: error = "Group item missing ID." raise MissingRequiredFieldError(error) @@ -123,7 +132,7 @@ def remove_user(self, group_item, user_id): # Adds 1 user to 1 group @api(version="2.0") - def add_user(self, group_item, user_id): + def add_user(self, group_item: GroupItem, user_id: str) -> UserItem: if not group_item.id: error = "Group item missing ID." raise MissingRequiredFieldError(error) diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index 4cdbcc5be..99870ac34 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -1,20 +1,25 @@ -from .endpoint import Endpoint, api +import logging + +from .endpoint import QuerysetEndpoint, api from .exceptions import JobCancelledException, JobFailedException from .. import JobItem, BackgroundJobItem, PaginationItem from ..request_options import RequestOptionsBase from ...exponential_backoff import ExponentialBackoffTimer -import logging - logger = logging.getLogger("tableau.endpoint.jobs") -class Jobs(Endpoint): +from typing import List, Optional, Tuple, Union + + +class Jobs(QuerysetEndpoint): @property def baseurl(self): return "{0}/sites/{1}/jobs".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="2.6") - def get(self, job_id=None, req_options=None): + def get( + self, job_id: Optional[str] = None, req_options: Optional[RequestOptionsBase] = None + ) -> Tuple[List[BackgroundJobItem], PaginationItem]: # Backwards Compatibility fix until we rev the major version if job_id is not None and isinstance(job_id, str): import warnings @@ -31,21 +36,22 @@ def get(self, job_id=None, req_options=None): return jobs, pagination_item @api(version="3.1") - def cancel(self, job_id): - id_ = getattr(job_id, "id", job_id) - url = "{0}/{1}".format(self.baseurl, id_) + def cancel(self, job_id: Union[str, JobItem]): + if isinstance(job_id, JobItem): + job_id = job_id.id + assert isinstance(job_id, str) + url = "{0}/{1}".format(self.baseurl, job_id) return self.put_request(url) @api(version="2.6") - def get_by_id(self, job_id): + def get_by_id(self, job_id: str) -> JobItem: logger.info("Query for information about job " + job_id) url = "{0}/{1}".format(self.baseurl, job_id) server_response = self.get_request(url) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job - @api(version="2.6") - def wait_for_job(self, job_id, *, timeout=None): + def wait_for_job(self, job_id: Union[str, JobItem], *, timeout: Optional[float] = None) -> JobItem: if isinstance(job_id, JobItem): job_id = job_id.id assert isinstance(job_id, str) diff --git a/tableauserverclient/server/endpoint/metadata_endpoint.py b/tableauserverclient/server/endpoint/metadata_endpoint.py index adc7b2666..06339fa79 100644 --- a/tableauserverclient/server/endpoint/metadata_endpoint.py +++ b/tableauserverclient/server/endpoint/metadata_endpoint.py @@ -1,8 +1,8 @@ -from .endpoint import Endpoint, api -from .exceptions import GraphQLError, InvalidGraphQLQuery -import logging import json +import logging +from .endpoint import Endpoint, api +from .exceptions import GraphQLError, InvalidGraphQLQuery logger = logging.getLogger("tableau.endpoint.metadata") diff --git a/tableauserverclient/server/endpoint/metrics_endpoint.py b/tableauserverclient/server/endpoint/metrics_endpoint.py new file mode 100644 index 000000000..fba2632a4 --- /dev/null +++ b/tableauserverclient/server/endpoint/metrics_endpoint.py @@ -0,0 +1,78 @@ +from .endpoint import QuerysetEndpoint, api +from .exceptions import MissingRequiredFieldError +from .permissions_endpoint import _PermissionsEndpoint +from .dqw_endpoint import _DataQualityWarningEndpoint +from .resource_tagger import _ResourceTagger +from .. import RequestFactory, PaginationItem +from ...models.metric_item import MetricItem + +import logging +import copy + +from typing import List, Optional, TYPE_CHECKING, Tuple + +if TYPE_CHECKING: + from ..request_options import RequestOptions + from ...server import Server + + +logger = logging.getLogger("tableau.endpoint.metrics") + + +class Metrics(QuerysetEndpoint): + def __init__(self, parent_srv: "Server") -> None: + super(Metrics, self).__init__(parent_srv) + self._resource_tagger = _ResourceTagger(parent_srv) + self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) + self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "metric") + + @property + def baseurl(self) -> str: + return "{0}/sites/{1}/metrics".format(self.parent_srv.baseurl, self.parent_srv.site_id) + + # Get all metrics + @api(version="3.9") + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[MetricItem], PaginationItem]: + logger.info("Querying all metrics on site") + url = self.baseurl + server_response = self.get_request(url, req_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + all_metric_items = MetricItem.from_response(server_response.content, self.parent_srv.namespace) + return all_metric_items, pagination_item + + # Get 1 metric by id + @api(version="3.9") + def get_by_id(self, metric_id: str) -> MetricItem: + if not metric_id: + error = "Metric ID undefined." + raise ValueError(error) + logger.info("Querying single metric (ID: {0})".format(metric_id)) + url = "{0}/{1}".format(self.baseurl, metric_id) + server_response = self.get_request(url) + return MetricItem.from_response(server_response.content, self.parent_srv.namespace)[0] + + # Delete 1 metric by id + @api(version="3.9") + def delete(self, metric_id: str) -> None: + if not metric_id: + error = "Metric ID undefined." + raise ValueError(error) + url = "{0}/{1}".format(self.baseurl, metric_id) + self.delete_request(url) + logger.info("Deleted single metric (ID: {0})".format(metric_id)) + + # Update metric + @api(version="3.9") + def update(self, metric_item: MetricItem) -> MetricItem: + if not metric_item.id: + error = "Metric item missing ID. Metric must be retrieved from server first." + raise MissingRequiredFieldError(error) + + self._resource_tagger.update_tags(self.baseurl, metric_item) + + # Update the metric itself + url = "{0}/{1}".format(self.baseurl, metric_item.id) + update_req = RequestFactory.Metric.update_req(metric_item) + server_response = self.put_request(url, update_req) + logger.info("Updated metric item (ID: {0})".format(metric_item.id)) + return MetricItem.from_response(server_response.content, self.parent_srv.namespace)[0] diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py index 5013a0bef..10a1d9fac 100644 --- a/tableauserverclient/server/endpoint/permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/permissions_endpoint.py @@ -5,20 +5,28 @@ from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError +from typing import Callable, TYPE_CHECKING, List, Union logger = logging.getLogger(__name__) +if TYPE_CHECKING: + from ...models import DatasourceItem, ProjectItem, WorkbookItem, ViewItem + from ..server import Server + from ..request_options import RequestOptions + +TableauItem = Union["DatasourceItem", "ProjectItem", "WorkbookItem", "ViewItem"] + class _PermissionsEndpoint(Endpoint): """Adds permission model to another endpoint - Tableau permissions model is identical between objects but they are nested under + Tableau permissions model is identical between objects, but they are nested under the parent object endpoint (i.e. permissions for workbooks are under - /workbooks/:id/permission). This class is meant to be instantated inside a + /workbooks/:id/permission). This class is meant to be instantiated inside a parent endpoint which has these supported endpoints """ - def __init__(self, parent_srv, owner_baseurl): + def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> None: super(_PermissionsEndpoint, self).__init__(parent_srv) # owner_baseurl is the baseurl of the parent. The MUST be a lambda @@ -26,7 +34,7 @@ def __init__(self, parent_srv, owner_baseurl): # populated without, we will get a sign-in error self.owner_baseurl = owner_baseurl - def update(self, resource, permissions): + def update(self, resource: TableauItem, permissions: List[PermissionsRule]) -> List[PermissionsRule]: url = "{0}/{1}/permissions".format(self.owner_baseurl(), resource.id) update_req = RequestFactory.Permission.add_req(permissions) response = self.put_request(url, update_req) @@ -35,7 +43,7 @@ def update(self, resource, permissions): return permissions - def delete(self, resource, rules): + def delete(self, resource: TableauItem, rules: Union[PermissionsRule, List[PermissionsRule]]): # Delete is the only endpoint that doesn't take a list of rules # so let's fake it to keep it consistent # TODO that means we need error handling around the call @@ -62,7 +70,7 @@ def delete(self, resource, rules): "Deleted permission for {0} {1} item {2}".format(rule.grantee.tag_name, rule.grantee.id, resource.id) ) - def populate(self, item): + def populate(self, item: TableauItem): if not item.id: error = "Server item is missing ID. Item must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -73,7 +81,7 @@ def permission_fetcher(): item._set_permissions(permission_fetcher) logger.info("Populated permissions for item (ID: {0})".format(item.id)) - def _get_permissions(self, item, req_options=None): + def _get_permissions(self, item: TableauItem, req_options: "RequestOptions" = None): url = "{0}/{1}/permissions".format(self.owner_baseurl(), item.id) server_response = self.get_request(url, req_options) permissions = PermissionsRule.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 72286e570..b21ba3682 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -1,28 +1,33 @@ -from .endpoint import api, Endpoint +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 .default_permissions_endpoint import _DefaultPermissionsEndpoint +from .. import RequestFactory, RequestOptions, ProjectItem, PaginationItem, Permission -from .. import RequestFactory, ProjectItem, PaginationItem, Permission +logger = logging.getLogger("tableau.endpoint.projects") -import logging +from typing import List, Optional, Tuple, TYPE_CHECKING -logger = logging.getLogger("tableau.endpoint.projects") +if TYPE_CHECKING: + from ..server import Server + from ..request_options import RequestOptions -class Projects(Endpoint): - def __init__(self, parent_srv): +class Projects(QuerysetEndpoint): + def __init__(self, parent_srv: "Server") -> None: super(Projects, self).__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl) @property - def baseurl(self): + def baseurl(self) -> str: return "{0}/sites/{1}/projects".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="2.0") - def get(self, req_options=None): + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[ProjectItem], PaginationItem]: logger.info("Querying all projects on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -31,7 +36,7 @@ def get(self, req_options=None): return all_project_items, pagination_item @api(version="2.0") - def delete(self, project_id): + def delete(self, project_id: str) -> None: if not project_id: error = "Project ID undefined." raise ValueError(error) @@ -40,29 +45,31 @@ def delete(self, project_id): logger.info("Deleted single project (ID: {0})".format(project_id)) @api(version="2.0") - def update(self, project_item): + def update(self, project_item: ProjectItem, samples: bool = False) -> ProjectItem: if not project_item.id: error = "Project item missing ID." raise MissingRequiredFieldError(error) + params = {"params": {RequestOptions.Field.PublishSamples: samples}} url = "{0}/{1}".format(self.baseurl, project_item.id) update_req = RequestFactory.Project.update_req(project_item) - server_response = self.put_request(url, update_req) + server_response = self.put_request(url, update_req, XML_CONTENT_TYPE, params) logger.info("Updated project item (ID: {0})".format(project_item.id)) updated_project = ProjectItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_project @api(version="2.0") - def create(self, project_item): + def create(self, project_item: ProjectItem, samples: bool = False) -> ProjectItem: + params = {"params": {RequestOptions.Field.PublishSamples: samples}} url = self.baseurl create_req = RequestFactory.Project.create_req(project_item) - server_response = self.post_request(url, create_req) + 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] logger.info("Created new project (ID: {0})".format(new_project.id)) return new_project @api(version="2.0") - def populate_permissions(self, item): + def populate_permissions(self, item: ProjectItem) -> None: self._permissions.populate(item) @api(version="2.0") diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index a38c66ebe..d5bc4dccb 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -1,10 +1,11 @@ +import copy +import logging +import urllib.parse + from .endpoint import Endpoint from .exceptions import EndpointUnavailableError, ServerResponseError from .. import RequestFactory from ...models.tag_item import TagItem -import logging -import copy -import urllib.parse logger = logging.getLogger("tableau.endpoint.resource_tagger") diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index d582dca26..21c828989 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -1,27 +1,33 @@ -from .endpoint import Endpoint, api -from .exceptions import MissingRequiredFieldError -from .. import RequestFactory, PaginationItem, ScheduleItem, TaskItem -import logging import copy +import logging +import warnings from collections import namedtuple +from typing import TYPE_CHECKING, Callable, List, Optional, Tuple, Union + +from .endpoint import Endpoint, api, parameter_added_in +from .exceptions import MissingRequiredFieldError +from .. import RequestFactory, PaginationItem, ScheduleItem, TaskItem logger = logging.getLogger("tableau.endpoint.schedules") -# Oh to have a first class Result concept in Python... AddResponse = namedtuple("AddResponse", ("result", "error", "warnings", "task_created")) OK = AddResponse(result=True, error=None, warnings=None, task_created=None) +if TYPE_CHECKING: + from ..request_options import RequestOptions + from ...models import DatasourceItem, WorkbookItem, FlowItem + class Schedules(Endpoint): @property - def baseurl(self): + def baseurl(self) -> str: return "{0}/schedules".format(self.parent_srv.baseurl) @property - def siteurl(self): + def siteurl(self) -> str: return "{0}/sites/{1}/schedules".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="2.3") - def get(self, req_options=None): + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[ScheduleItem], PaginationItem]: logger.info("Querying all schedules") url = self.baseurl server_response = self.get_request(url, req_options) @@ -29,8 +35,18 @@ def get(self, req_options=None): all_schedule_items = ScheduleItem.from_response(server_response.content, self.parent_srv.namespace) return all_schedule_items, pagination_item + @api(version="3.8") + def get_by_id(self, schedule_id): + if not schedule_id: + error = "No Schedule ID provided" + raise ValueError(error) + logger.info("Querying a single schedule by id ({})".format(schedule_id)) + url = "{0}/{1}".format(self.baseurl, schedule_id) + server_response = self.get_request(url) + return ScheduleItem.from_response(server_response.content, self.parent_srv.namespace)[0] + @api(version="2.3") - def delete(self, schedule_id): + def delete(self, schedule_id: str) -> None: if not schedule_id: error = "Schedule ID undefined" raise ValueError(error) @@ -39,7 +55,7 @@ def delete(self, schedule_id): logger.info("Deleted single schedule (ID: {0})".format(schedule_id)) @api(version="2.3") - def update(self, schedule_item): + def update(self, schedule_item: ScheduleItem) -> ScheduleItem: if not schedule_item.id: error = "Schedule item missing ID." raise MissingRequiredFieldError(error) @@ -52,7 +68,7 @@ def update(self, schedule_item): return updated_schedule._parse_common_tags(server_response.content, self.parent_srv.namespace) @api(version="2.3") - def create(self, schedule_item): + def create(self, schedule_item: ScheduleItem) -> ScheduleItem: if schedule_item.interval_item is None: error = "Interval item must be defined." raise MissingRequiredFieldError(error) @@ -65,42 +81,71 @@ def create(self, schedule_item): return new_schedule @api(version="2.8") + @parameter_added_in(flow="3.3") def add_to_schedule( self, - schedule_id, - workbook=None, - datasource=None, - task_type=TaskItem.Type.ExtractRefresh, - ): - def add_to(resource, type_, req_factory): - id_ = resource.id - url = "{0}/{1}/{2}s".format(self.siteurl, schedule_id, type_) - add_req = req_factory(id_, task_type=task_type) - response = self.put_request(url, add_req) - - error, warnings, task_created = ScheduleItem.parse_add_to_schedule_response( - response, self.parent_srv.namespace - ) - if task_created: - logger.info("Added {} to {} to schedule {}".format(type_, id_, schedule_id)) - - if error is not None or warnings is not None: - return AddResponse( - result=False, - error=error, - warnings=warnings, - task_created=task_created, - ) - else: - return OK - - items = [] + schedule_id: str, + workbook: "WorkbookItem" = None, + datasource: "DatasourceItem" = None, + flow: "FlowItem" = None, + task_type: str = None, + ) -> List[AddResponse]: + + # There doesn't seem to be a good reason to allow one item of each type? + if workbook and datasource: + warnings.warn("Passing in multiple items for add_to_schedule will be deprecated", PendingDeprecationWarning) + items: List[ + Tuple[str, Union[WorkbookItem, FlowItem, DatasourceItem], str, Callable[[Optional[str], str], bytes], str] + ] = [] if workbook is not None: - items.append((workbook, "workbook", RequestFactory.Schedule.add_workbook_req)) + if not task_type: + task_type = TaskItem.Type.ExtractRefresh + items.append((schedule_id, workbook, "workbook", RequestFactory.Schedule.add_workbook_req, task_type)) if datasource is not None: - items.append((datasource, "datasource", RequestFactory.Schedule.add_datasource_req)) - - results = (add_to(*x) for x in items) + if not task_type: + task_type = TaskItem.Type.ExtractRefresh + items.append((schedule_id, datasource, "datasource", RequestFactory.Schedule.add_datasource_req, task_type)) + if flow is not None and not (workbook or datasource): # Cannot pass a flow with any other type + if not task_type: + task_type = TaskItem.Type.RunFlow + items.append( + (schedule_id, flow, "flow", RequestFactory.Schedule.add_flow_req, task_type) + ) # type:ignore[arg-type] + + results = (self._add_to(*x) for x in items) # list() is needed for python 3.x compatibility - return list(filter(lambda x: not x.result, results)) + return list(filter(lambda x: not x.result, results)) # type:ignore[arg-type] + + def _add_to( + self, + schedule_id, + resource: Union["DatasourceItem", "WorkbookItem", "FlowItem"], + type_: str, + req_factory: Callable[ + [ + str, + str, + ], + bytes, + ], + item_task_type, + ) -> AddResponse: + id_ = resource.id + url = "{0}/{1}/{2}s".format(self.siteurl, schedule_id, type_) + add_req = req_factory(id_, task_type=item_task_type) # type: ignore[call-arg, arg-type] + response = self.put_request(url, add_req) + + error, warnings, task_created = ScheduleItem.parse_add_to_schedule_response(response, self.parent_srv.namespace) + if task_created: + logger.info("Added {} to {} to schedule {}".format(type_, id_, schedule_id)) + + if error is not None or warnings is not None: + return AddResponse( + result=False, + error=error, + warnings=warnings, + task_created=task_created, + ) + else: + return OK diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index ca3715fca..5c9461d1c 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -1,3 +1,5 @@ +import logging + from .endpoint import Endpoint, api from .exceptions import ( ServerResponseError, @@ -5,7 +7,6 @@ EndpointUnavailableError, ) from ...models import ServerInfoItem -import logging logger = logging.getLogger("tableau.endpoint.server_info") diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index 9446a01a8..bdf281fb9 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -1,21 +1,26 @@ +import copy +import logging + from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError from .. import RequestFactory, SiteItem, PaginationItem -import copy -import logging - logger = logging.getLogger("tableau.endpoint.sites") +from typing import TYPE_CHECKING, List, Optional, Tuple + +if TYPE_CHECKING: + from ..request_options import RequestOptions + class Sites(Endpoint): @property - def baseurl(self): + def baseurl(self) -> str: return "{0}/sites".format(self.parent_srv.baseurl) # Gets all sites @api(version="2.0") - def get(self, req_options=None): + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[SiteItem], PaginationItem]: logger.info("Querying all sites on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -25,7 +30,7 @@ def get(self, req_options=None): # Gets 1 site by id @api(version="2.0") - def get_by_id(self, site_id): + def get_by_id(self, site_id: str) -> SiteItem: if not site_id: error = "Site ID undefined." raise ValueError(error) @@ -36,7 +41,7 @@ def get_by_id(self, site_id): # Gets 1 site by name @api(version="2.0") - def get_by_name(self, site_name): + def get_by_name(self, site_name: str) -> SiteItem: if not site_name: error = "Site Name undefined." raise ValueError(error) @@ -47,7 +52,7 @@ def get_by_name(self, site_name): # Gets 1 site by content url @api(version="2.0") - def get_by_content_url(self, content_url): + def get_by_content_url(self, content_url: str) -> SiteItem: if content_url is None: error = "Content URL undefined." raise ValueError(error) @@ -58,7 +63,7 @@ def get_by_content_url(self, content_url): # Update site @api(version="2.0") - def update(self, site_item): + def update(self, site_item: SiteItem) -> SiteItem: if not site_item.id: error = "Site item missing ID." raise MissingRequiredFieldError(error) @@ -76,7 +81,7 @@ def update(self, site_item): # Delete 1 site object @api(version="2.0") - def delete(self, site_id): + def delete(self, site_id: str) -> None: if not site_id: error = "Site ID undefined." raise ValueError(error) @@ -91,7 +96,7 @@ def delete(self, site_id): # Create new site @api(version="2.0") - def create(self, site_item): + def create(self, site_item: SiteItem) -> SiteItem: if site_item.admin_mode: if site_item.admin_mode == SiteItem.AdminMode.ContentOnly and site_item.user_quota: error = "You cannot set admin_mode to ContentOnly and also set a user quota" @@ -105,7 +110,7 @@ def create(self, site_item): return new_site @api(version="3.5") - def encrypt_extracts(self, site_id): + def encrypt_extracts(self, site_id: str) -> None: if not site_id: error = "Site ID undefined." raise ValueError(error) @@ -114,7 +119,7 @@ def encrypt_extracts(self, site_id): self.post_request(url, empty_req) @api(version="3.5") - def decrypt_extracts(self, site_id): + def decrypt_extracts(self, site_id: str) -> None: if not site_id: error = "Site ID undefined." raise ValueError(error) @@ -123,7 +128,7 @@ def decrypt_extracts(self, site_id): self.post_request(url, empty_req) @api(version="3.5") - def re_encrypt_extracts(self, site_id): + def re_encrypt_extracts(self, site_id: str) -> None: if not site_id: error = "Site ID undefined." raise ValueError(error) diff --git a/tableauserverclient/server/endpoint/subscriptions_endpoint.py b/tableauserverclient/server/endpoint/subscriptions_endpoint.py index 1a66e8ac5..6b929524e 100644 --- a/tableauserverclient/server/endpoint/subscriptions_endpoint.py +++ b/tableauserverclient/server/endpoint/subscriptions_endpoint.py @@ -1,19 +1,24 @@ +import logging + from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError from .. import RequestFactory, SubscriptionItem, PaginationItem -import logging - logger = logging.getLogger("tableau.endpoint.subscriptions") +from typing import List, Optional, TYPE_CHECKING, Tuple + +if TYPE_CHECKING: + from ..request_options import RequestOptions + class Subscriptions(Endpoint): @property - def baseurl(self): + def baseurl(self) -> str: return "{0}/sites/{1}/subscriptions".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="2.3") - def get(self, req_options=None): + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[SubscriptionItem], PaginationItem]: logger.info("Querying all subscriptions for the site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -23,7 +28,7 @@ def get(self, req_options=None): return all_subscriptions, pagination_item @api(version="2.3") - def get_by_id(self, subscription_id): + def get_by_id(self, subscription_id: str) -> SubscriptionItem: if not subscription_id: error = "No Subscription ID provided" raise ValueError(error) @@ -33,7 +38,7 @@ def get_by_id(self, subscription_id): return SubscriptionItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="2.3") - def create(self, subscription_item): + def create(self, subscription_item: SubscriptionItem) -> SubscriptionItem: if not subscription_item: error = "No Susbcription provided" raise ValueError(error) @@ -44,7 +49,7 @@ def create(self, subscription_item): return SubscriptionItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="2.3") - def delete(self, subscription_id): + def delete(self, subscription_id: str) -> None: if not subscription_id: error = "Subscription ID undefined." raise ValueError(error) @@ -53,7 +58,7 @@ def delete(self, subscription_id): logger.info("Deleted subscription (ID: {0})".format(subscription_id)) @api(version="2.3") - def update(self, subscription_item): + def update(self, subscription_item: SubscriptionItem) -> SubscriptionItem: if not subscription_item.id: error = "Subscription item missing ID. Subscription must be retrieved from server first." raise MissingRequiredFieldError(error) diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index ac53484db..e41ab07ca 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -1,12 +1,11 @@ +import logging + +from .dqw_endpoint import _DataQualityWarningEndpoint from .endpoint import api, Endpoint from .exceptions import MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint -from .dqw_endpoint import _DataQualityWarningEndpoint -from ..pager import Pager - from .. import RequestFactory, TableItem, ColumnItem, PaginationItem - -import logging +from ..pager import Pager logger = logging.getLogger("tableau.endpoint.tables") diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index aaa5069c3..339952704 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -1,9 +1,9 @@ +import logging + from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError from .. import TaskItem, PaginationItem, RequestFactory -import logging - logger = logging.getLogger("tableau.endpoint.tasks") diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 6adbf92fb..a1984d5d6 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -1,3 +1,7 @@ +import copy +import logging +from typing import List, Tuple + from .endpoint import QuerysetEndpoint, api from .exceptions import MissingRequiredFieldError from .. import ( @@ -10,20 +14,17 @@ ) from ..pager import Pager -import copy -import logging - logger = logging.getLogger("tableau.endpoint.users") class Users(QuerysetEndpoint): @property - def baseurl(self): + def baseurl(self) -> str: return "{0}/sites/{1}/users".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Gets all users @api(version="2.0") - def get(self, req_options=None): + def get(self, req_options: RequestOptions = None) -> Tuple[List[UserItem], PaginationItem]: logger.info("Querying all users on site") if req_options is None: @@ -38,7 +39,7 @@ def get(self, req_options=None): # Gets 1 user by id @api(version="2.0") - def get_by_id(self, user_id): + def get_by_id(self, user_id: str) -> UserItem: if not user_id: error = "User ID undefined." raise ValueError(error) @@ -49,7 +50,7 @@ def get_by_id(self, user_id): # Update user @api(version="2.0") - def update(self, user_item, password=None): + def update(self, user_item: UserItem, password: str = None) -> UserItem: if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) @@ -63,7 +64,7 @@ def update(self, user_item, password=None): # Delete 1 user by id @api(version="2.0") - def remove(self, user_id): + def remove(self, user_id: str) -> None: if not user_id: error = "User ID undefined." raise ValueError(error) @@ -73,7 +74,7 @@ def remove(self, user_id): # Add new user to site @api(version="2.0") - def add(self, user_item): + def add(self, user_item: UserItem) -> UserItem: url = self.baseurl add_req = RequestFactory.User.add_req(user_item) server_response = self.post_request(url, add_req) @@ -83,7 +84,7 @@ def add(self, user_item): # Get workbooks for user @api(version="2.0") - def populate_workbooks(self, user_item, req_options=None): + def populate_workbooks(self, user_item: UserItem, req_options: RequestOptions = None) -> None: if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) @@ -93,7 +94,9 @@ def wb_pager(): user_item._set_workbooks(wb_pager) - def _get_wbs_for_user(self, user_item, req_options=None): + def _get_wbs_for_user( + self, user_item: UserItem, req_options: RequestOptions = None + ) -> Tuple[List[WorkbookItem], PaginationItem]: url = "{0}/{1}/workbooks".format(self.baseurl, user_item.id) server_response = self.get_request(url, req_options) logger.info("Populated workbooks for user (ID: {0})".format(user_item.id)) @@ -101,12 +104,12 @@ def _get_wbs_for_user(self, user_item, req_options=None): pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) return workbook_item, pagination_item - def populate_favorites(self, user_item): + def populate_favorites(self, user_item: UserItem) -> None: self.parent_srv.favorites.get(user_item) # Get groups for user @api(version="3.7") - def populate_groups(self, user_item, req_options=None): + def populate_groups(self, user_item: UserItem, req_options: RequestOptions = None) -> None: if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) @@ -119,7 +122,9 @@ def groups_for_user_pager(): user_item._set_groups(groups_for_user_pager) - def _get_groups_for_user(self, user_item, req_options=None): + def _get_groups_for_user( + self, user_item: UserItem, req_options: RequestOptions = None + ) -> Tuple[List[GroupItem], PaginationItem]: url = "{0}/{1}/groups".format(self.baseurl, user_item.id) server_response = self.get_request(url, req_options) logger.info("Populated groups for user (ID: {0})".format(user_item.id)) diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index a00e7f145..cb652fbc0 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -1,14 +1,19 @@ +import logging +from contextlib import closing + from .endpoint import QuerysetEndpoint, api from .exceptions import MissingRequiredFieldError -from .resource_tagger import _ResourceTagger from .permissions_endpoint import _PermissionsEndpoint +from .resource_tagger import _ResourceTagger from .. import ViewItem, PaginationItem -from contextlib import closing -import logging - logger = logging.getLogger("tableau.endpoint.views") +from typing import Iterable, List, Optional, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from ..request_options import RequestOptions, CSVRequestOptions, PDFRequestOptions, ImageRequestOptions + class Views(QuerysetEndpoint): def __init__(self, parent_srv): @@ -18,15 +23,17 @@ def __init__(self, parent_srv): # Used because populate_preview_image functionaliy requires workbook endpoint @property - def siteurl(self): + def siteurl(self) -> str: return "{0}/sites/{1}".format(self.parent_srv.baseurl, self.parent_srv.site_id) @property - def baseurl(self): + def baseurl(self) -> str: return "{0}/views".format(self.siteurl) @api(version="2.2") - def get(self, req_options=None, usage=False): + def get( + self, req_options: Optional["RequestOptions"] = None, usage: bool = False + ) -> Tuple[List[ViewItem], PaginationItem]: logger.info("Querying all views on site") url = self.baseurl if usage: @@ -37,7 +44,7 @@ def get(self, req_options=None, usage=False): return all_view_items, pagination_item @api(version="3.1") - def get_by_id(self, view_id): + def get_by_id(self, view_id: str) -> ViewItem: if not view_id: error = "View item missing ID." raise MissingRequiredFieldError(error) @@ -47,7 +54,7 @@ def get_by_id(self, view_id): return ViewItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="2.0") - def populate_preview_image(self, view_item): + def populate_preview_image(self, view_item: ViewItem) -> None: if not view_item.id or not view_item.workbook_id: error = "View item missing ID or workbook ID." raise MissingRequiredFieldError(error) @@ -58,14 +65,14 @@ def image_fetcher(): view_item._set_preview_image(image_fetcher) logger.info("Populated preview image for view (ID: {0})".format(view_item.id)) - def _get_preview_for_view(self, view_item): + def _get_preview_for_view(self, view_item: ViewItem) -> bytes: url = "{0}/workbooks/{1}/views/{2}/previewImage".format(self.siteurl, view_item.workbook_id, view_item.id) server_response = self.get_request(url) image = server_response.content return image @api(version="2.5") - def populate_image(self, view_item, req_options=None): + def populate_image(self, view_item: ViewItem, req_options: Optional["ImageRequestOptions"] = None) -> None: if not view_item.id: error = "View item missing ID." raise MissingRequiredFieldError(error) @@ -76,14 +83,14 @@ def image_fetcher(): view_item._set_image(image_fetcher) logger.info("Populated image for view (ID: {0})".format(view_item.id)) - def _get_view_image(self, view_item, req_options): + def _get_view_image(self, view_item: ViewItem, req_options: Optional["ImageRequestOptions"]) -> bytes: url = "{0}/{1}/image".format(self.baseurl, view_item.id) server_response = self.get_request(url, req_options) image = server_response.content return image @api(version="2.7") - def populate_pdf(self, view_item, req_options=None): + def populate_pdf(self, view_item: ViewItem, req_options: Optional["PDFRequestOptions"] = None) -> None: if not view_item.id: error = "View item missing ID." raise MissingRequiredFieldError(error) @@ -94,14 +101,14 @@ def pdf_fetcher(): view_item._set_pdf(pdf_fetcher) logger.info("Populated pdf for view (ID: {0})".format(view_item.id)) - def _get_view_pdf(self, view_item, req_options): + def _get_view_pdf(self, view_item: ViewItem, req_options: Optional["PDFRequestOptions"]) -> bytes: url = "{0}/{1}/pdf".format(self.baseurl, view_item.id) server_response = self.get_request(url, req_options) pdf = server_response.content return pdf @api(version="2.7") - def populate_csv(self, view_item, req_options=None): + def populate_csv(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"] = None) -> None: if not view_item.id: error = "View item missing ID." raise MissingRequiredFieldError(error) @@ -112,15 +119,34 @@ def csv_fetcher(): view_item._set_csv(csv_fetcher) logger.info("Populated csv for view (ID: {0})".format(view_item.id)) - def _get_view_csv(self, view_item, req_options): + def _get_view_csv(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"]) -> Iterable[bytes]: url = "{0}/{1}/data".format(self.baseurl, view_item.id) with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: csv = server_response.iter_content(1024) return csv + @api(version="3.8") + def populate_excel(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"] = None) -> None: + if not view_item.id: + error = "View item missing ID." + raise MissingRequiredFieldError(error) + + def excel_fetcher(): + return self._get_view_excel(view_item, req_options) + + view_item._set_excel(excel_fetcher) + logger.info("Populated excel for view (ID: {0})".format(view_item.id)) + + def _get_view_excel(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"]) -> Iterable[bytes]: + url = "{0}/{1}/crosstab/excel".format(self.baseurl, view_item.id) + + with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: + excel = server_response.iter_content(1024) + return excel + @api(version="3.2") - def populate_permissions(self, item): + def populate_permissions(self, item: ViewItem) -> None: self._permissions.populate(item) @api(version="3.2") @@ -132,7 +158,7 @@ def delete_permission(self, item, capability_item): return self._permissions.delete(item, capability_item) # Update view. Currently only tags can be updated - def update(self, view_item): + def update(self, view_item: ViewItem) -> ViewItem: if not view_item.id: error = "View item missing ID. View must be retrieved from server first." raise MissingRequiredFieldError(error) diff --git a/tableauserverclient/server/endpoint/webhooks_endpoint.py b/tableauserverclient/server/endpoint/webhooks_endpoint.py index 6f5135ac1..b28f3e5f1 100644 --- a/tableauserverclient/server/endpoint/webhooks_endpoint.py +++ b/tableauserverclient/server/endpoint/webhooks_endpoint.py @@ -1,22 +1,28 @@ +import logging + from .endpoint import Endpoint, api -from ...models import WebhookItem, PaginationItem from .. import RequestFactory - -import logging +from ...models import WebhookItem, PaginationItem logger = logging.getLogger("tableau.endpoint.webhooks") +from typing import List, Optional, TYPE_CHECKING, Tuple + +if TYPE_CHECKING: + from ..server import Server + from ..request_options import RequestOptions + class Webhooks(Endpoint): - def __init__(self, parent_srv): + def __init__(self, parent_srv: "Server") -> None: super(Webhooks, self).__init__(parent_srv) @property - def baseurl(self): + def baseurl(self) -> str: return "{0}/sites/{1}/webhooks".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="3.6") - def get(self, req_options=None): + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[WebhookItem], PaginationItem]: logger.info("Querying all Webhooks on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -25,7 +31,7 @@ def get(self, req_options=None): return all_webhook_items, pagination_item @api(version="3.6") - def get_by_id(self, webhook_id): + def get_by_id(self, webhook_id: str) -> WebhookItem: if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) @@ -35,7 +41,7 @@ def get_by_id(self, webhook_id): return WebhookItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="3.6") - def delete(self, webhook_id): + def delete(self, webhook_id: str) -> None: if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) @@ -44,7 +50,7 @@ def delete(self, webhook_id): logger.info("Deleted single webhook (ID: {0})".format(webhook_id)) @api(version="3.6") - def create(self, webhook_item): + def create(self, webhook_item: WebhookItem) -> WebhookItem: url = self.baseurl create_req = RequestFactory.Webhook.create_req(webhook_item) server_response = self.post_request(url, create_req) @@ -54,7 +60,7 @@ def create(self, webhook_item): return new_webhook @api(version="3.6") - def test(self, webhook_id): + def test(self, webhook_id: str): if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index a3f14c291..901d0e62a 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -1,21 +1,48 @@ +import cgi +import copy +import io +import logging +import os +from contextlib import closing +from pathlib import Path +from typing import ( + List, + Optional, + Sequence, + Tuple, + TYPE_CHECKING, + Union, +) + from .endpoint import QuerysetEndpoint, api, parameter_added_in from .exceptions import InternalServerError, MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint from .resource_tagger import _ResourceTagger from .. import RequestFactory, WorkbookItem, ConnectionItem, ViewItem, PaginationItem -from ...models.job_item import JobItem from ...filesys_helpers import ( to_filename, make_download_path, get_file_type, get_file_object_size, ) +from ...models.job_item import JobItem +from ...models.revision_item import RevisionItem + +from typing import ( + List, + Optional, + Sequence, + Tuple, + TYPE_CHECKING, + Union, +) -import os -import logging -import copy -import cgi -from contextlib import closing +if TYPE_CHECKING: + from ..server import Server + from ..request_options import RequestOptions + from .. import DatasourceItem + from ...models.connection_credentials import ConnectionCredentials + from .schedules_endpoint import AddResponse # The maximum size of a file that can be published in a single request is 64MB FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB @@ -23,21 +50,26 @@ ALLOWED_FILE_EXTENSIONS = ["twb", "twbx"] logger = logging.getLogger("tableau.endpoint.workbooks") +FilePath = Union[str, os.PathLike] +FileObject = Union[io.BufferedReader, io.BytesIO] +PathOrFile = Union[FilePath, FileObject] class Workbooks(QuerysetEndpoint): - def __init__(self, parent_srv): + def __init__(self, parent_srv: "Server") -> None: super(Workbooks, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) + return None + @property - def baseurl(self): + def baseurl(self) -> str: return "{0}/sites/{1}/workbooks".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Get all workbooks on site @api(version="2.0") - def get(self, req_options=None): + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[WorkbookItem], PaginationItem]: logger.info("Querying all workbooks on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -47,7 +79,7 @@ def get(self, req_options=None): # Get 1 workbook @api(version="2.0") - def get_by_id(self, workbook_id): + def get_by_id(self, workbook_id: str) -> WorkbookItem: if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) @@ -57,7 +89,7 @@ def get_by_id(self, workbook_id): return WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="2.8") - def refresh(self, workbook_id): + def refresh(self, workbook_id: str) -> JobItem: id_ = getattr(workbook_id, "id", workbook_id) url = "{0}/{1}/refresh".format(self.baseurl, id_) empty_req = RequestFactory.Empty.empty_req() @@ -67,7 +99,13 @@ def refresh(self, workbook_id): # create one or more extracts on 1 workbook, optionally encrypted @api(version="3.5") - def create_extract(self, workbook_item, encrypt=False, includeAll=True, datasources=None): + def create_extract( + self, + workbook_item: WorkbookItem, + encrypt: bool = False, + includeAll: bool = True, + datasources: Optional[List["DatasourceItem"]] = None, + ) -> JobItem: id_ = getattr(workbook_item, "id", workbook_item) url = "{0}/{1}/createExtract?encrypt={2}".format(self.baseurl, id_, encrypt) @@ -77,16 +115,17 @@ def create_extract(self, workbook_item, encrypt=False, includeAll=True, datasour return new_job # delete all the extracts on 1 workbook - @api(version="3.5") - def delete_extract(self, workbook_item): + @api(version="3.3") + def delete_extract(self, workbook_item: WorkbookItem, includeAll: bool = True) -> None: id_ = getattr(workbook_item, "id", workbook_item) url = "{0}/{1}/deleteExtract".format(self.baseurl, id_) - empty_req = RequestFactory.Empty.empty_req() - server_response = self.post_request(url, empty_req) + datasource_req = RequestFactory.Workbook.embedded_extract_req(includeAll, None) + server_response = self.post_request(url, datasource_req) + new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] # Delete 1 workbook by id @api(version="2.0") - def delete(self, workbook_id): + def delete(self, workbook_id: str) -> None: if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) @@ -96,7 +135,7 @@ def delete(self, workbook_id): # Update workbook @api(version="2.0") - def update(self, workbook_item): + def update(self, workbook_item: WorkbookItem) -> WorkbookItem: if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -120,7 +159,7 @@ def update_conn(self, *args, **kwargs): # Update workbook_connection @api(version="2.3") - def update_connection(self, workbook_item, connection_item): + def update_connection(self, workbook_item: WorkbookItem, connection_item: ConnectionItem) -> ConnectionItem: url = "{0}/{1}/connections/{2}".format(self.baseurl, workbook_item.id, connection_item.id) update_req = RequestFactory.Connection.update_req(connection_item) server_response = self.put_request(url, update_req) @@ -135,7 +174,13 @@ def update_connection(self, workbook_item, connection_item): @api(version="2.0") @parameter_added_in(no_extract="2.5") @parameter_added_in(include_extract="2.5") - def download(self, workbook_id, filepath=None, include_extract=True, no_extract=None): + def download( + self, + workbook_id: str, + filepath: FilePath = None, + include_extract: bool = True, + no_extract: Optional[bool] = None, + ) -> str: if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) @@ -167,18 +212,18 @@ def download(self, workbook_id, filepath=None, include_extract=True, no_extract= # Get all views of workbook @api(version="2.0") - def populate_views(self, workbook_item, usage=False): + def populate_views(self, workbook_item: WorkbookItem, usage: bool = False) -> None: if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) - def view_fetcher(): + def view_fetcher() -> List[ViewItem]: return self._get_views_for_workbook(workbook_item, usage) workbook_item._set_views(view_fetcher) logger.info("Populated views for workbook (ID: {0})".format(workbook_item.id)) - def _get_views_for_workbook(self, workbook_item, usage): + def _get_views_for_workbook(self, workbook_item: WorkbookItem, usage: bool) -> List[ViewItem]: url = "{0}/{1}/views".format(self.baseurl, workbook_item.id) if usage: url += "?includeUsageStatistics=true" @@ -192,7 +237,7 @@ def _get_views_for_workbook(self, workbook_item, usage): # Get all connections of workbook @api(version="2.0") - def populate_connections(self, workbook_item): + def populate_connections(self, workbook_item: WorkbookItem) -> None: if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -203,7 +248,9 @@ def connection_fetcher(): workbook_item._set_connections(connection_fetcher) logger.info("Populated connections for workbook (ID: {0})".format(workbook_item.id)) - def _get_workbook_connections(self, workbook_item, req_options=None): + def _get_workbook_connections( + self, workbook_item: WorkbookItem, req_options: "RequestOptions" = None + ) -> List[ConnectionItem]: url = "{0}/{1}/connections".format(self.baseurl, workbook_item.id) server_response = self.get_request(url, req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) @@ -211,44 +258,62 @@ def _get_workbook_connections(self, workbook_item, req_options=None): # Get the pdf of the entire workbook if its tabs are enabled, pdf of the default view if its tabs are disabled @api(version="3.4") - def populate_pdf(self, workbook_item, req_options=None): + def populate_pdf(self, workbook_item: WorkbookItem, req_options: "RequestOptions" = None) -> None: if not workbook_item.id: error = "Workbook item missing ID." raise MissingRequiredFieldError(error) - def pdf_fetcher(): + def pdf_fetcher() -> bytes: return self._get_wb_pdf(workbook_item, req_options) workbook_item._set_pdf(pdf_fetcher) logger.info("Populated pdf for workbook (ID: {0})".format(workbook_item.id)) - def _get_wb_pdf(self, workbook_item, req_options): + def _get_wb_pdf(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"]) -> bytes: url = "{0}/{1}/pdf".format(self.baseurl, workbook_item.id) server_response = self.get_request(url, req_options) pdf = server_response.content return pdf + @api(version="3.8") + def populate_powerpoint(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None) -> None: + if not workbook_item.id: + error = "Workbook item missing ID." + raise MissingRequiredFieldError(error) + + def pptx_fetcher() -> bytes: + return self._get_wb_pptx(workbook_item, req_options) + + workbook_item._set_powerpoint(pptx_fetcher) + logger.info("Populated powerpoint for workbook (ID: {0})".format(workbook_item.id)) + + def _get_wb_pptx(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"]) -> bytes: + url = "{0}/{1}/powerpoint".format(self.baseurl, workbook_item.id) + server_response = self.get_request(url, req_options) + pptx = server_response.content + return pptx + # Get preview image of workbook @api(version="2.0") - def populate_preview_image(self, workbook_item): + def populate_preview_image(self, workbook_item: WorkbookItem) -> None: if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) - def image_fetcher(): + def image_fetcher() -> bytes: return self._get_wb_preview_image(workbook_item) workbook_item._set_preview_image(image_fetcher) logger.info("Populated preview image for workbook (ID: {0})".format(workbook_item.id)) - def _get_wb_preview_image(self, workbook_item): + def _get_wb_preview_image(self, workbook_item: WorkbookItem) -> bytes: url = "{0}/{1}/previewImage".format(self.baseurl, workbook_item.id) server_response = self.get_request(url) preview_image = server_response.content return preview_image @api(version="2.0") - def populate_permissions(self, item): + def populate_permissions(self, item: WorkbookItem) -> None: self._permissions.populate(item) @api(version="2.0") @@ -259,20 +324,19 @@ def update_permissions(self, resource, rules): def delete_permission(self, item, capability_item): return self._permissions.delete(item, capability_item) - # Publishes workbook. Chunking method if file over 64MB @api(version="2.0") @parameter_added_in(as_job="3.0") @parameter_added_in(connections="2.8") def publish( self, - workbook_item, - file, - mode, - connection_credentials=None, - connections=None, - as_job=False, - hidden_views=None, - skip_connection_check=False, + workbook_item: WorkbookItem, + file: PathOrFile, + 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, ): if connection_credentials is not None: @@ -283,7 +347,7 @@ def publish( DeprecationWarning, ) - try: + if isinstance(file, (str, os.PathLike)): # Expect file to be a filepath if not os.path.isfile(file): error = "File path does not lead to an existing file." @@ -300,7 +364,7 @@ def publish( error = "Only {} files can be published as workbooks.".format(", ".join(ALLOWED_FILE_EXTENSIONS)) raise ValueError(error) - except TypeError: + elif isinstance(file, (io.BytesIO, io.BufferedReader)): # Expect file to be a file object file_size = get_file_object_size(file) @@ -322,6 +386,9 @@ def publish( # This is needed when publishing the workbook in a single request filename = "{}.{}".format(workbook_item.name, file_extension) + else: + raise TypeError("file should be a filepath or file object.") + if not hasattr(self.parent_srv.PublishMode, mode): error = "Invalid mode defined." raise ValueError(error) @@ -355,13 +422,16 @@ def publish( else: logger.info("Publishing {0} to server".format(filename)) - try: + if isinstance(file, (str, Path)): with open(file, "rb") as f: file_contents = f.read() - except TypeError: + elif isinstance(file, (io.BytesIO, io.BufferedReader)): file_contents = file.read() + 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, @@ -389,3 +459,81 @@ def publish( new_workbook = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] logger.info("Published {0} (ID: {1})".format(workbook_item.name, new_workbook.id)) return new_workbook + + # Populate workbook item's revisions + @api(version="2.3") + def populate_revisions(self, workbook_item: WorkbookItem) -> None: + if not workbook_item.id: + error = "Workbook item missing ID. Workbook must be retrieved from server first." + raise MissingRequiredFieldError(error) + + def revisions_fetcher(): + return self._get_workbook_revisions(workbook_item) + + workbook_item._set_revisions(revisions_fetcher) + logger.info("Populated revisions for workbook (ID: {0})".format(workbook_item.id)) + + def _get_workbook_revisions( + self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None + ) -> List[RevisionItem]: + url = "{0}/{1}/revisions".format(self.baseurl, workbook_item.id) + server_response = self.get_request(url, req_options) + revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, workbook_item) + return revisions + + # Download 1 workbook revision by revision number + @api(version="2.3") + def download_revision( + self, + workbook_id: str, + revision_number: str, + filepath: Optional[PathOrFile] = None, + include_extract: bool = True, + no_extract: Optional[bool] = None, + ) -> str: + if not workbook_id: + error = "Workbook ID undefined." + raise ValueError(error) + 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" + + with closing(self.get_request(url, parameters={"stream": True})) as server_response: + _, params = cgi.parse_header(server_response.headers["Content-Disposition"]) + filename = to_filename(os.path.basename(params["filename"])) + + download_path = make_download_path(filepath, filename) + + with open(download_path, "wb") as f: + for chunk in server_response.iter_content(1024): # 1KB + f.write(chunk) + logger.info( + "Downloaded workbook revision {0} to {1} (ID: {2})".format(revision_number, download_path, workbook_id) + ) + return os.path.abspath(download_path) + + @api(version="2.3") + def delete_revision(self, workbook_id: str, revision_number: str) -> None: + if workbook_id is None or revision_number is None: + raise ValueError + url = "/".join([self.baseurl, workbook_id, "revisions", revision_number]) + + self.delete_request(url) + logger.info("Deleted single workbook revision (ID: {0}) (Revision: {1})".format(workbook_id, revision_number)) + + # a convenience method + @api(version="2.8") + def schedule_extract_refresh( + self, schedule_id: str, item: WorkbookItem + ) -> List["AddResponse"]: # actually should return a task + return self.parent_srv.schedules.add_to_schedule(schedule_id, workbook=item) diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index 3dbb830fa..64a7107aa 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -1,6 +1,7 @@ -from .request_options import RequestOptions from .filter import Filter +from .request_options import RequestOptions from .sort import Sort +import math def to_camel_case(word): @@ -15,11 +16,66 @@ def __init__(self, model): self._pagination_item = None def __iter__(self): - self._fetch_all() - return iter(self._result_cache) + # 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 + self._fetch_all() + yield from self._result_cache def __getitem__(self, k): - return list(self)[k] + page = self.page_number + size = self.page_size + + # Create a range object for quick checking if k is in the cached result. + page_range = range((page - 1) * size, page * size) + + if isinstance(k, slice): + # Parse out the slice object, and assume reasonable defaults if no value provided. + step = k.step if k.step is not None else 1 + start = k.start if k.start is not None else 0 + stop = k.stop if k.stop is not None else self.total_available + + # If negative values present in slice, convert to positive values + if start < 0: + start += self.total_available + if stop < 0: + stop += self.total_available + if start < stop and step < 0: + # Since slicing is left inclusive and right exclusive, shift + # the start and stop values by 1 to keep that behavior + start, stop = stop - 1, start - 1 + slice_stop = stop if stop > 0 else None + k = slice(start, slice_stop, step) + + # Fetch items from cache if present, otherwise, recursively fetch. + k_range = range(start, stop, step) + if all(i in page_range for i in k_range): + return self._result_cache[k] + return [self[i] for i in k_range] + + if k < 0: + k += self.total_available + + if k in page_range: + # Fetch item from cache if present + 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.request_options.pagenumber = max(1, math.ceil(k / size)) + return self[k] + else: + # If k is unreasonable, raise an IndexError. + raise IndexError def _fetch_all(self): """ @@ -43,7 +99,9 @@ def page_size(self): self._fetch_all() return self._pagination_item.page_size - def filter(self, **kwargs): + def filter(self, *invalid, **kwargs): + if invalid: + raise RuntimeError(f"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)) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 4cbea1443..7e4038979 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1,12 +1,23 @@ +from os import name import xml.etree.ElementTree as ET +from typing import Any, Dict, List, Optional, Tuple, Iterable from requests.packages.urllib3.fields import RequestField from requests.packages.urllib3.filepost import encode_multipart_formdata +from tableauserverclient.models.metric_item import MetricItem + +from ..models import ConnectionItem +from ..models import DataAlertItem +from ..models import FlowItem +from ..models import ProjectItem +from ..models import SiteItem +from ..models import SubscriptionItem from ..models import TaskItem, UserItem, GroupItem, PermissionsRule, FavoriteItem +from ..models import WebhookItem -def _add_multipart(parts): +def _add_multipart(parts: Dict) -> Tuple[Any, str]: mime_multipart_parts = list() for name, (filename, data, content_type) in parts.items(): multipart_part = RequestField(name=name, data=data, filename=filename) @@ -87,22 +98,26 @@ def update_req(self, column_item): class DataAlertRequest(object): - def add_user_to_alert(self, alert_item, user_id): + def add_user_to_alert(self, alert_item: "DataAlertItem", user_id: str) -> bytes: xml_request = ET.Element("tsRequest") user_element = ET.SubElement(xml_request, "user") user_element.attrib["id"] = user_id return ET.tostring(xml_request) - def update_req(self, alert_item): + def update_req(self, alert_item: "DataAlertItem") -> bytes: xml_request = ET.Element("tsRequest") dataAlert_element = ET.SubElement(xml_request, "dataAlert") - dataAlert_element.attrib["subject"] = alert_item.subject - dataAlert_element.attrib["frequency"] = alert_item.frequency.lower() - dataAlert_element.attrib["public"] = alert_item.public + if alert_item.subject is not None: + dataAlert_element.attrib["subject"] = alert_item.subject + if alert_item.frequency is not None: + dataAlert_element.attrib["frequency"] = alert_item.frequency.lower() + if alert_item.public is not None: + dataAlert_element.attrib["public"] = str(alert_item.public).lower() owner = ET.SubElement(dataAlert_element, "owner") - owner.attrib["id"] = alert_item.owner_id + if alert_item.owner_id is not None: + owner.attrib["id"] = alert_item.owner_id return ET.tostring(xml_request) @@ -232,38 +247,8 @@ def update_req(self, database_item): return ET.tostring(xml_request) -class DQWRequest(object): - def add_req(self, dqw_item): - xml_request = ET.Element("tsRequest") - dqw_element = ET.SubElement(xml_request, "dataQualityWarning") - - dqw_element.attrib["isActive"] = str(dqw_item.active).lower() - dqw_element.attrib["isSevere"] = str(dqw_item.severe).lower() - - dqw_element.attrib["type"] = dqw_item.warning_type - - if dqw_item.message: - dqw_element.attrib["message"] = str(dqw_item.message) - - return ET.tostring(xml_request) - - def update_req(self, database_item): - xml_request = ET.Element("tsRequest") - dqw_element = ET.SubElement(xml_request, "dataQualityWarning") - - dqw_element.attrib["isActive"] = str(dqw_item.active).lower() - dqw_element.attrib["isSevere"] = str(dqw_item.severe).lower() - - dqw_element.attrib["type"] = dqw_item.warning_type - - if dqw_item.message: - dqw_element.attrib["message"] = str(dqw_item.message) - - return ET.tostring(xml_request) - - class FavoriteRequest(object): - def _add_to_req(self, id_, target_type, label): + def _add_to_req(self, id_: str, target_type: str, label: str) -> bytes: """ @@ -277,16 +262,39 @@ def _add_to_req(self, id_, target_type, label): return ET.tostring(xml_request) - def add_datasource_req(self, id_, name): + def add_datasource_req(self, id_: Optional[str], name: Optional[str]) -> bytes: + if id_ is None: + raise ValueError("id must exist to add to favorites") + if name is None: + raise ValueError("Name must exist to add to favorites.") return self._add_to_req(id_, FavoriteItem.Type.Datasource, name) - def add_project_req(self, id_, name): + def add_flow_req(self, id_: Optional[str], name: Optional[str]) -> bytes: + if id_ is None: + raise ValueError("id must exist to add to favorites") + if name is None: + raise ValueError("Name must exist to add to favorites.") + return self._add_to_req(id_, FavoriteItem.Type.Flow, name) + + def add_project_req(self, id_: Optional[str], name: Optional[str]) -> bytes: + if id_ is None: + raise ValueError("id must exist to add to favorites") + if name is None: + raise ValueError("Name must exist to add to favorites.") return self._add_to_req(id_, FavoriteItem.Type.Project, name) - def add_view_req(self, id_, name): + def add_view_req(self, id_: Optional[str], name: Optional[str]) -> bytes: + if id_ is None: + raise ValueError("id must exist to add to favorites") + if name is None: + raise ValueError("Name must exist to add to favorites.") return self._add_to_req(id_, FavoriteItem.Type.View, name) - def add_workbook_req(self, id_, name): + def add_workbook_req(self, id_: Optional[str], name: Optional[str]) -> bytes: + if id_ is None: + raise ValueError("id must exist to add to favorites") + if name is None: + raise ValueError("Name must exist to add to favorites.") return self._add_to_req(id_, FavoriteItem.Type.Workbook, name) @@ -300,10 +308,11 @@ def chunk_req(self, chunk): class FlowRequest(object): - def _generate_xml(self, flow_item, connections=None): + def _generate_xml(self, flow_item: "FlowItem", connections: Optional[List["ConnectionItem"]] = None) -> bytes: xml_request = ET.Element("tsRequest") flow_element = ET.SubElement(xml_request, "flow") - flow_element.attrib["name"] = flow_item.name + if flow_item.name is not None: + flow_element.attrib["name"] = flow_item.name project_element = ET.SubElement(flow_element, "project") project_element.attrib["id"] = flow_item.project_id @@ -313,7 +322,7 @@ def _generate_xml(self, flow_item, connections=None): _add_connections_element(connections_element, connection) return ET.tostring(xml_request) - def update_req(self, flow_item): + def update_req(self, flow_item: "FlowItem") -> bytes: xml_request = ET.Element("tsRequest") flow_element = ET.SubElement(xml_request, "flow") if flow_item.project_id: @@ -325,7 +334,13 @@ def update_req(self, flow_item): return ET.tostring(xml_request) - def publish_req(self, flow_item, filename, file_contents, connections=None): + def publish_req( + self, + flow_item: "FlowItem", + filename: str, + file_contents: bytes, + connections: Optional[List["ConnectionItem"]] = None, + ) -> Tuple[Any, str]: xml_request = self._generate_xml(flow_item, connections) parts = { @@ -334,7 +349,7 @@ def publish_req(self, flow_item, filename, file_contents, connections=None): } return _add_multipart(parts) - def publish_req_chunked(self, flow_item, connections=None): + def publish_req_chunked(self, flow_item, connections=None) -> Tuple[Any, str]: xml_request = self._generate_xml(flow_item, connections) parts = {"request_payload": ("", xml_request, "text/xml")} @@ -342,24 +357,30 @@ def publish_req_chunked(self, flow_item, connections=None): class GroupRequest(object): - def add_user_req(self, user_id): + def add_user_req(self, user_id: str) -> bytes: xml_request = ET.Element("tsRequest") user_element = ET.SubElement(xml_request, "user") user_element.attrib["id"] = user_id return ET.tostring(xml_request) - def create_local_req(self, group_item): + def create_local_req(self, group_item: GroupItem) -> bytes: xml_request = ET.Element("tsRequest") group_element = ET.SubElement(xml_request, "group") - group_element.attrib["name"] = group_item.name + if group_item.name is not None: + group_element.attrib["name"] = group_item.name + else: + raise ValueError("Group name must be populated") if group_item.minimum_site_role is not None: group_element.attrib["minimumSiteRole"] = group_item.minimum_site_role return ET.tostring(xml_request) - def create_ad_req(self, group_item): + def create_ad_req(self, group_item: GroupItem) -> bytes: xml_request = ET.Element("tsRequest") group_element = ET.SubElement(xml_request, "group") - group_element.attrib["name"] = group_item.name + if group_item.name is not None: + group_element.attrib["name"] = group_item.name + else: + raise ValueError("Group name must be populated") import_element = ET.SubElement(group_element, "import") import_element.attrib["source"] = "ActiveDirectory" if group_item.domain_name is None: @@ -373,7 +394,7 @@ def create_ad_req(self, group_item): import_element.attrib["siteRole"] = group_item.minimum_site_role return ET.tostring(xml_request) - def update_req(self, group_item, default_site_role=None): + 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 @@ -388,13 +409,20 @@ def update_req(self, group_item, default_site_role=None): xml_request = ET.Element("tsRequest") group_element = ET.SubElement(xml_request, "group") - group_element.attrib["name"] = group_item.name + + if group_item.name is not None: + group_element.attrib["name"] = group_item.name + else: + raise ValueError("Group name must be populated") if group_item.domain_name is not None and group_item.domain_name != "local": # Import element is only accepted in the request for AD groups import_element = ET.SubElement(group_element, "import") import_element.attrib["source"] = "ActiveDirectory" import_element.attrib["domainName"] = group_item.domain_name - import_element.attrib["siteRole"] = group_item.minimum_site_role + if isinstance(group_item.minimum_site_role, str): + import_element.attrib["siteRole"] = group_item.minimum_site_role + else: + raise ValueError("Minimum site role must be provided.") if group_item.license_mode is not None: import_element.attrib["grantLicenseMode"] = group_item.license_mode else: @@ -406,7 +434,7 @@ def update_req(self, group_item, default_site_role=None): class PermissionRequest(object): - def add_req(self, rules): + def add_req(self, rules: Iterable[PermissionsRule]) -> bytes: xml_request = ET.Element("tsRequest") permissions_element = ET.SubElement(xml_request, "permissions") @@ -428,7 +456,7 @@ def _add_all_capabilities(self, capabilities_element, capabilities_map): class ProjectRequest(object): - def update_req(self, project_item): + def update_req(self, project_item: "ProjectItem") -> bytes: xml_request = ET.Element("tsRequest") project_element = ET.SubElement(xml_request, "project") if project_item.name: @@ -441,7 +469,7 @@ def update_req(self, project_item): project_element.attrib["parentProjectId"] = project_item.parent_id return ET.tostring(xml_request) - def create_req(self, project_item): + def create_req(self, project_item: "ProjectItem") -> bytes: xml_request = ET.Element("tsRequest") project_element = ET.SubElement(xml_request, "project") project_element.attrib["name"] = project_item.name @@ -504,7 +532,7 @@ def update_req(self, schedule_item): single_interval_element.attrib[expression] = value return ET.tostring(xml_request) - def _add_to_req(self, id_, target_type, task_type=TaskItem.Type.ExtractRefresh): + def _add_to_req(self, id_: Optional[str], target_type: str, task_type: str = TaskItem.Type.ExtractRefresh) -> bytes: """ @@ -513,6 +541,8 @@ def _add_to_req(self, id_, target_type, task_type=TaskItem.Type.ExtractRefresh): """ + if not isinstance(id_, str): + raise ValueError(f"id_ should be a string, reeceived: {type(id_)}") xml_request = ET.Element("tsRequest") task_element = ET.SubElement(xml_request, "task") task = ET.SubElement(task_element, task_type) @@ -521,15 +551,18 @@ def _add_to_req(self, id_, target_type, task_type=TaskItem.Type.ExtractRefresh): return ET.tostring(xml_request) - def add_workbook_req(self, id_, task_type=TaskItem.Type.ExtractRefresh): + def add_workbook_req(self, id_: Optional[str], task_type: str = TaskItem.Type.ExtractRefresh) -> bytes: return self._add_to_req(id_, "workbook", task_type) - def add_datasource_req(self, id_, task_type=TaskItem.Type.ExtractRefresh): + def add_datasource_req(self, id_: Optional[str], task_type: str = TaskItem.Type.ExtractRefresh) -> bytes: return self._add_to_req(id_, "datasource", task_type) + def add_flow_req(self, id_: Optional[str], task_type: str = TaskItem.Type.RunFlow) -> bytes: + return self._add_to_req(id_, "flow", task_type) + class SiteRequest(object): - def update_req(self, site_item): + def update_req(self, site_item: "SiteItem"): xml_request = ET.Element("tsRequest") site_element = ET.SubElement(xml_request, "site") if site_item.name: @@ -635,7 +668,7 @@ def update_req(self, site_item): return ET.tostring(xml_request) - def create_req(self, site_item): + def create_req(self, site_item: "SiteItem"): xml_request = ET.Element("tsRequest") site_element = ET.SubElement(xml_request, "site") site_element.attrib["name"] = site_item.name @@ -769,7 +802,7 @@ def add_req(self, tag_set): class UserRequest(object): - def update_req(self, user_item, password): + def update_req(self, user_item: UserItem, password: Optional[str]) -> bytes: xml_request = ET.Element("tsRequest") user_element = ET.SubElement(xml_request, "user") if user_item.fullname: @@ -785,11 +818,18 @@ def update_req(self, user_item, password): user_element.attrib["password"] = password return ET.tostring(xml_request) - def add_req(self, user_item): + def add_req(self, user_item: UserItem) -> bytes: xml_request = ET.Element("tsRequest") user_element = ET.SubElement(xml_request, "user") - user_element.attrib["name"] = user_item.name - user_element.attrib["siteRole"] = user_item.site_role + if isinstance(user_item.name, str): + user_element.attrib["name"] = user_item.name + else: + raise ValueError(f"{user_item} missing name.") + if isinstance(user_item.site_role, str): + user_element.attrib["siteRole"] = user_item.site_role + else: + raise ValueError(f"{user_item} must have site role populated.") + if user_item.auth_setting: user_element.attrib["authSetting"] = user_item.auth_setting return ET.tostring(xml_request) @@ -823,8 +863,19 @@ def _generate_xml( _add_connections_element(connections_element, connection) 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 hidden_views: + for view_name in workbook_item.hidden_views: _add_hiddenview_element(views_element, view_name) return ET.tostring(xml_request) @@ -930,7 +981,7 @@ def run_req(self, xml_request, task_item): class SubscriptionRequest(object): @_tsrequest_wrapped - def create_req(self, xml_request, subscription_item): + def create_req(self, xml_request: ET.Element, subscription_item: "SubscriptionItem") -> bytes: subscription_element = ET.SubElement(xml_request, "subscription") # Main attributes @@ -963,7 +1014,7 @@ def create_req(self, xml_request, subscription_item): return ET.tostring(xml_request) @_tsrequest_wrapped - def update_req(self, xml_request, subscription_item): + def update_req(self, xml_request: ET.Element, subscription_item: "SubscriptionItem") -> bytes: subscription = ET.SubElement(xml_request, "subscription") # Main attributes @@ -1000,17 +1051,46 @@ def empty_req(self, xml_request): class WebhookRequest(object): @_tsrequest_wrapped - def create_req(self, xml_request, webhook_item): + def create_req(self, xml_request: ET.Element, webhook_item: "WebhookItem") -> bytes: webhook = ET.SubElement(xml_request, "webhook") - webhook.attrib["name"] = webhook_item.name + if isinstance(webhook_item.name, str): + webhook.attrib["name"] = webhook_item.name + else: + raise ValueError(f"Name must be provided for {webhook_item}") source = ET.SubElement(webhook, "webhook-source") - ET.SubElement(source, webhook_item._event) + if isinstance(webhook_item._event, str): + ET.SubElement(source, webhook_item._event) + else: + raise ValueError(f"_event for Webhook must be provided. {webhook_item}") destination = ET.SubElement(webhook, "webhook-destination") post = ET.SubElement(destination, "webhook-destination-http") post.attrib["method"] = "POST" - post.attrib["url"] = webhook_item.url + if isinstance(webhook_item.url, str): + post.attrib["url"] = webhook_item.url + else: + raise ValueError(f"URL must be provided on {webhook_item}") + + return ET.tostring(xml_request) + + +class MetricRequest: + @_tsrequest_wrapped + def update_req(self, xml_request: ET.Element, metric_item: MetricItem) -> bytes: + metric_element = ET.SubElement(xml_request, "metric") + if metric_item.id is not None: + metric_element.attrib["id"] = metric_item.id + if metric_item.name is not None: + metric_element.attrib["name"] = metric_item.name + if metric_item.description is not None: + metric_element.attrib["description"] = metric_item.description + if metric_item.suspended is not None: + metric_element.attrib["suspended"] = str(metric_item.suspended).lower() + if metric_item.project_id is not None: + ET.SubElement(metric_element, "project", {"id": metric_item.project_id}) + if metric_item.owner_id is not None: + ET.SubElement(metric_element, "owner", {"id": metric_item.owner_id}) return ET.tostring(xml_request) @@ -1028,6 +1108,7 @@ class RequestFactory(object): Fileupload = FileuploadRequest() Flow = FlowRequest() Group = GroupRequest() + Metric = MetricRequest() Permission = PermissionRequest() Project = ProjectRequest() Schedule = ScheduleRequest() diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 3047691a9..36ffccd8e 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -48,6 +48,7 @@ class Field: OwnerName = "ownerName" Progress = "progress" ProjectName = "projectName" + PublishSamples = "publishSamples" SiteRole = "siteRole" Subtitle = "subtitle" Tags = "tags" diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 56fc47849..4522bc272 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -1,7 +1,8 @@ -import xml.etree.ElementTree as ET +from distutils.version import LooseVersion as Version +import urllib3 +import requests +from defusedxml.ElementTree import fromstring -from .exceptions import NotSignedInError -from ..namespace import Namespace from .endpoint import ( Sites, Views, @@ -25,19 +26,19 @@ Favorites, DataAlerts, Fileuploads, - FlowRuns + FlowRuns, + Metrics, ) from .endpoint.exceptions import ( EndpointUnavailableError, ServerInfoEndpointNotFoundError, ) +from .exceptions import NotSignedInError +from ..namespace import Namespace import requests -try: - from distutils2.version import NormalizedVersion as Version -except ImportError: - from distutils.version import LooseVersion as Version +from distutils.version import LooseVersion as Version _PRODUCT_TO_REST_VERSION = { "10.0": "2.3", @@ -54,7 +55,7 @@ class PublishMode: Overwrite = "Overwrite" CreateNew = "CreateNew" - def __init__(self, server_address, use_server_version=False): + def __init__(self, server_address, use_server_version=True, http_options=None): self._server_address = server_address self._auth_token = None self._site_id = None @@ -87,12 +88,18 @@ def __init__(self, server_address, use_server_version=False): self.fileuploads = Fileuploads(self) self._namespace = Namespace() self.flow_runs = FlowRuns(self) + self.metrics = Metrics(self) + + if http_options: + self.add_http_options(http_options) if use_server_version: self.use_server_version() def add_http_options(self, options_dict): self._http_options.update(options_dict) + if options_dict.get("verify") == False: + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) def clear_http_options(self): self._http_options = dict() @@ -110,7 +117,7 @@ def _set_auth(self, site_id, user_id, auth_token): def _get_legacy_version(self): response = self._session.get(self.server_address + "/auth?format=xml") - info_xml = ET.fromstring(response.content) + info_xml = fromstring(response.content) prod_version = info_xml.find(".//product_version").text version = _PRODUCT_TO_REST_VERSION.get(prod_version, "2.1") # 2.1 return version diff --git a/test/_utils.py b/test/_utils.py index 626838f23..8527aaf8c 100644 --- a/test/_utils.py +++ b/test/_utils.py @@ -1,8 +1,8 @@ -from contextlib import contextmanager -import unittest import os.path +import unittest +from contextlib import contextmanager -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") def asset(filename): @@ -10,8 +10,8 @@ def asset(filename): def read_xml_asset(filename): - with open(asset(filename), 'rb') as f: - return f.read().decode('utf-8') + with open(asset(filename), "rb") as f: + return f.read().decode("utf-8") def read_xml_assets(*args): @@ -28,7 +28,7 @@ def sleep_mock(interval): def get_time(): return mock_time - + try: patch = unittest.mock.patch except AttributeError: diff --git a/test/assets/datasource_revision.xml b/test/assets/datasource_revision.xml new file mode 100644 index 000000000..598c8ad45 --- /dev/null +++ b/test/assets/datasource_revision.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/flow_get_by_id.xml b/test/assets/flow_get_by_id.xml new file mode 100644 index 000000000..d1c626105 --- /dev/null +++ b/test/assets/flow_get_by_id.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/metrics_get.xml b/test/assets/metrics_get.xml new file mode 100644 index 000000000..566af1074 --- /dev/null +++ b/test/assets/metrics_get.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/metrics_get_by_id.xml b/test/assets/metrics_get_by_id.xml new file mode 100644 index 000000000..30652da0f --- /dev/null +++ b/test/assets/metrics_get_by_id.xml @@ -0,0 +1,16 @@ + + + + + + + + + \ No newline at end of file diff --git a/test/assets/metrics_update.xml b/test/assets/metrics_update.xml new file mode 100644 index 000000000..30652da0f --- /dev/null +++ b/test/assets/metrics_update.xml @@ -0,0 +1,16 @@ + + + + + + + + + \ No newline at end of file diff --git a/test/assets/populate_excel.xlsx b/test/assets/populate_excel.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..3cf6115c7e11e2f8b54eb956c471fab1716b1411 GIT binary patch literal 6623 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 literal 0 HcmV?d00001 diff --git a/test/assets/populate_powerpoint.pptx b/test/assets/populate_powerpoint.pptx new file mode 100644 index 0000000000000000000000000000000000000000..dbf979c063261cfb70624dd7c30c57b647398d72 GIT binary patch literal 363648 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�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

XYA^00F`_h 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

4`LS|A9&53qCBJvIs7*|42j+*-mANyr{C+wl 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 literal 0 HcmV?d00001 diff --git a/test/assets/request_option_slicing_queryset.xml b/test/assets/request_option_slicing_queryset.xml new file mode 100644 index 000000000..34708c911 --- /dev/null +++ b/test/assets/request_option_slicing_queryset.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/schedule_add_flow.xml b/test/assets/schedule_add_flow.xml new file mode 100644 index 000000000..9934c38e5 --- /dev/null +++ b/test/assets/schedule_add_flow.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/test/assets/schedule_get_by_id.xml b/test/assets/schedule_get_by_id.xml new file mode 100644 index 000000000..943416beb --- /dev/null +++ b/test/assets/schedule_get_by_id.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/test/assets/workbook_revision.xml b/test/assets/workbook_revision.xml new file mode 100644 index 000000000..598c8ad45 --- /dev/null +++ b/test/assets/workbook_revision.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/test_auth.py b/test/test_auth.py index 3dbf87737..40255f627 100644 --- a/test/test_auth.py +++ b/test/test_auth.py @@ -1,89 +1,93 @@ -import unittest import os.path +import unittest + import requests_mock + import tableauserverclient as TSC -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") -SIGN_IN_XML = os.path.join(TEST_ASSET_DIR, 'auth_sign_in.xml') -SIGN_IN_IMPERSONATE_XML = os.path.join(TEST_ASSET_DIR, 'auth_sign_in_impersonate.xml') -SIGN_IN_ERROR_XML = os.path.join(TEST_ASSET_DIR, 'auth_sign_in_error.xml') +SIGN_IN_XML = os.path.join(TEST_ASSET_DIR, "auth_sign_in.xml") +SIGN_IN_IMPERSONATE_XML = os.path.join(TEST_ASSET_DIR, "auth_sign_in_impersonate.xml") +SIGN_IN_ERROR_XML = os.path.join(TEST_ASSET_DIR, "auth_sign_in_error.xml") class AuthTests(unittest.TestCase): def setUp(self): - self.server = TSC.Server('http://test') + self.server = TSC.Server("http://test", False) self.baseurl = self.server.auth.baseurl def test_sign_in(self): - with open(SIGN_IN_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(SIGN_IN_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(self.baseurl + '/signin', text=response_xml) - tableau_auth = TSC.TableauAuth('testuser', 'password', site_id='Samples') + m.post(self.baseurl + "/signin", text=response_xml) + tableau_auth = TSC.TableauAuth("testuser", "password", site_id="Samples") self.server.auth.sign_in(tableau_auth) - self.assertEqual('eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l', self.server.auth_token) - self.assertEqual('6b7179ba-b82b-4f0f-91ed-812074ac5da6', self.server.site_id) - self.assertEqual('1a96d216-e9b8-497b-a82a-0b899a965e01', self.server.user_id) + self.assertEqual("eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l", self.server.auth_token) + self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", self.server.site_id) + self.assertEqual("1a96d216-e9b8-497b-a82a-0b899a965e01", self.server.user_id) def test_sign_in_with_personal_access_tokens(self): - with open(SIGN_IN_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(SIGN_IN_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(self.baseurl + '/signin', text=response_xml) - tableau_auth = TSC.PersonalAccessTokenAuth(token_name='mytoken', - personal_access_token='Random123Generated', site_id='Samples') + m.post(self.baseurl + "/signin", text=response_xml) + tableau_auth = TSC.PersonalAccessTokenAuth( + token_name="mytoken", personal_access_token="Random123Generated", site_id="Samples" + ) self.server.auth.sign_in(tableau_auth) - self.assertEqual('eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l', self.server.auth_token) - self.assertEqual('6b7179ba-b82b-4f0f-91ed-812074ac5da6', self.server.site_id) - self.assertEqual('1a96d216-e9b8-497b-a82a-0b899a965e01', self.server.user_id) + self.assertEqual("eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l", self.server.auth_token) + self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", self.server.site_id) + self.assertEqual("1a96d216-e9b8-497b-a82a-0b899a965e01", self.server.user_id) def test_sign_in_impersonate(self): - with open(SIGN_IN_IMPERSONATE_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(SIGN_IN_IMPERSONATE_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(self.baseurl + '/signin', text=response_xml) - tableau_auth = TSC.TableauAuth('testuser', 'password', - user_id_to_impersonate='dd2239f6-ddf1-4107-981a-4cf94e415794') + m.post(self.baseurl + "/signin", text=response_xml) + tableau_auth = TSC.TableauAuth( + "testuser", "password", user_id_to_impersonate="dd2239f6-ddf1-4107-981a-4cf94e415794" + ) self.server.auth.sign_in(tableau_auth) - self.assertEqual('MJonFA6HDyy2C3oqR13fRGqE6cmgzwq3', self.server.auth_token) - self.assertEqual('dad65087-b08b-4603-af4e-2887b8aafc67', self.server.site_id) - self.assertEqual('dd2239f6-ddf1-4107-981a-4cf94e415794', self.server.user_id) + self.assertEqual("MJonFA6HDyy2C3oqR13fRGqE6cmgzwq3", self.server.auth_token) + self.assertEqual("dad65087-b08b-4603-af4e-2887b8aafc67", self.server.site_id) + self.assertEqual("dd2239f6-ddf1-4107-981a-4cf94e415794", self.server.user_id) def test_sign_in_error(self): - with open(SIGN_IN_ERROR_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(SIGN_IN_ERROR_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(self.baseurl + '/signin', text=response_xml, status_code=401) - tableau_auth = TSC.TableauAuth('testuser', 'wrongpassword') + m.post(self.baseurl + "/signin", text=response_xml, status_code=401) + tableau_auth = TSC.TableauAuth("testuser", "wrongpassword") self.assertRaises(TSC.ServerResponseError, self.server.auth.sign_in, tableau_auth) def test_sign_in_invalid_token(self): - with open(SIGN_IN_ERROR_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(SIGN_IN_ERROR_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(self.baseurl + '/signin', text=response_xml, status_code=401) - tableau_auth = TSC.PersonalAccessTokenAuth(token_name='mytoken', personal_access_token='invalid') + m.post(self.baseurl + "/signin", text=response_xml, status_code=401) + tableau_auth = TSC.PersonalAccessTokenAuth(token_name="mytoken", personal_access_token="invalid") self.assertRaises(TSC.ServerResponseError, self.server.auth.sign_in, tableau_auth) def test_sign_in_without_auth(self): - with open(SIGN_IN_ERROR_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(SIGN_IN_ERROR_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(self.baseurl + '/signin', text=response_xml, status_code=401) - tableau_auth = TSC.TableauAuth('', '') + m.post(self.baseurl + "/signin", text=response_xml, status_code=401) + tableau_auth = TSC.TableauAuth("", "") self.assertRaises(TSC.ServerResponseError, self.server.auth.sign_in, tableau_auth) def test_sign_out(self): - with open(SIGN_IN_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(SIGN_IN_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(self.baseurl + '/signin', text=response_xml) - m.post(self.baseurl + '/signout', text='') - tableau_auth = TSC.TableauAuth('testuser', 'password') + m.post(self.baseurl + "/signin", text=response_xml) + m.post(self.baseurl + "/signout", text="") + tableau_auth = TSC.TableauAuth("testuser", "password") self.server.auth.sign_in(tableau_auth) self.server.auth.sign_out() @@ -92,33 +96,33 @@ def test_sign_out(self): self.assertIsNone(self.server._user_id) def test_switch_site(self): - self.server.version = '2.6' + self.server.version = "2.6" baseurl = self.server.auth.baseurl - site_id, user_id, auth_token = list('123') + site_id, user_id, auth_token = list("123") self.server._set_auth(site_id, user_id, auth_token) - with open(SIGN_IN_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(SIGN_IN_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(baseurl + '/switchSite', text=response_xml) - site = TSC.SiteItem('Samples', 'Samples') + m.post(baseurl + "/switchSite", text=response_xml) + site = TSC.SiteItem("Samples", "Samples") self.server.auth.switch_site(site) - self.assertEqual('eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l', self.server.auth_token) - self.assertEqual('6b7179ba-b82b-4f0f-91ed-812074ac5da6', self.server.site_id) - self.assertEqual('1a96d216-e9b8-497b-a82a-0b899a965e01', self.server.user_id) + self.assertEqual("eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l", self.server.auth_token) + self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", self.server.site_id) + self.assertEqual("1a96d216-e9b8-497b-a82a-0b899a965e01", self.server.user_id) def test_revoke_all_server_admin_tokens(self): self.server.version = "3.10" baseurl = self.server.auth.baseurl - with open(SIGN_IN_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(SIGN_IN_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(baseurl + '/signin', text=response_xml) - m.post(baseurl + '/revokeAllServerAdminTokens', text='') - tableau_auth = TSC.TableauAuth('testuser', 'password') + m.post(baseurl + "/signin", text=response_xml) + m.post(baseurl + "/revokeAllServerAdminTokens", text="") + tableau_auth = TSC.TableauAuth("testuser", "password") self.server.auth.sign_in(tableau_auth) self.server.auth.revoke_all_server_admin_tokens() - self.assertEqual('eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l', self.server.auth_token) - self.assertEqual('6b7179ba-b82b-4f0f-91ed-812074ac5da6', self.server.site_id) - self.assertEqual('1a96d216-e9b8-497b-a82a-0b899a965e01', self.server.user_id) + self.assertEqual("eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l", self.server.auth_token) + self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", self.server.site_id) + self.assertEqual("1a96d216-e9b8-497b-a82a-0b899a965e01", self.server.user_id) diff --git a/test/test_data_acceleration_report.py b/test/test_data_acceleration_report.py index 7722bf230..8f9f5a49e 100644 --- a/test/test_data_acceleration_report.py +++ b/test/test_data_acceleration_report.py @@ -1,20 +1,20 @@ import unittest -import os + import requests_mock -import xml.etree.ElementTree as ET + import tableauserverclient as TSC -from ._utils import read_xml_asset, read_xml_assets, asset +from ._utils import read_xml_asset -GET_XML = 'data_acceleration_report.xml' +GET_XML = "data_acceleration_report.xml" class DataAccelerationReportTests(unittest.TestCase): def setUp(self): - self.server = TSC.Server('http://test') + self.server = TSC.Server("http://test", False) # Fake signin - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.server.version = "3.8" self.baseurl = self.server.data_acceleration_report.baseurl diff --git a/test/test_dataalert.py b/test/test_dataalert.py index 7822d3000..d9e00a9db 100644 --- a/test/test_dataalert.py +++ b/test/test_dataalert.py @@ -1,100 +1,97 @@ import unittest -import os + import requests_mock -import xml.etree.ElementTree as ET + import tableauserverclient as TSC -from tableauserverclient.datetime_helpers import format_datetime -from tableauserverclient.server.endpoint.exceptions import InternalServerError -from tableauserverclient.server.request_factory import RequestFactory -from ._utils import read_xml_asset, read_xml_assets, asset +from ._utils import read_xml_asset -GET_XML = 'data_alerts_get.xml' -GET_BY_ID_XML = 'data_alerts_get_by_id.xml' -ADD_USER_TO_ALERT = 'data_alerts_add_user.xml' -UPDATE_XML = 'data_alerts_update.xml' +GET_XML = "data_alerts_get.xml" +GET_BY_ID_XML = "data_alerts_get_by_id.xml" +ADD_USER_TO_ALERT = "data_alerts_add_user.xml" +UPDATE_XML = "data_alerts_update.xml" class DataAlertTests(unittest.TestCase): - def setUp(self): - self.server = TSC.Server('http://test') + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) # Fake signin - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.server.version = "3.2" self.baseurl = self.server.data_alerts.baseurl - def test_get(self): + def test_get(self) -> None: response_xml = read_xml_asset(GET_XML) with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) all_alerts, pagination_item = self.server.data_alerts.get() self.assertEqual(1, pagination_item.total_available) - self.assertEqual('5ea59b45-e497-5673-8809-bfe213236f75', all_alerts[0].id) - self.assertEqual('Data Alert test', all_alerts[0].subject) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_alerts[0].creatorId) - self.assertEqual('2020-08-10T23:17:06Z', all_alerts[0].createdAt) - self.assertEqual('2020-08-10T23:17:06Z', all_alerts[0].updatedAt) - self.assertEqual('Daily', all_alerts[0].frequency) - self.assertEqual('true', all_alerts[0].public) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_alerts[0].owner_id) - self.assertEqual('Bob', all_alerts[0].owner_name) - self.assertEqual('d79634e1-6063-4ec9-95ff-50acbf609ff5', all_alerts[0].view_id) - self.assertEqual('ENDANGERED SAFARI', all_alerts[0].view_name) - self.assertEqual('6d13b0ca-043d-4d42-8c9d-3f3313ea3a00', all_alerts[0].workbook_id) - self.assertEqual('Safari stats', all_alerts[0].workbook_name) - self.assertEqual('5241e88d-d384-4fd7-9c2f-648b5247efc5', all_alerts[0].project_id) - self.assertEqual('Default', all_alerts[0].project_name) - - def test_get_by_id(self): + self.assertEqual("5ea59b45-e497-5673-8809-bfe213236f75", all_alerts[0].id) + self.assertEqual("Data Alert test", all_alerts[0].subject) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_alerts[0].creatorId) + self.assertEqual("2020-08-10T23:17:06Z", all_alerts[0].createdAt) + self.assertEqual("2020-08-10T23:17:06Z", all_alerts[0].updatedAt) + self.assertEqual("Daily", all_alerts[0].frequency) + self.assertEqual("true", all_alerts[0].public) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_alerts[0].owner_id) + self.assertEqual("Bob", all_alerts[0].owner_name) + self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", all_alerts[0].view_id) + self.assertEqual("ENDANGERED SAFARI", all_alerts[0].view_name) + self.assertEqual("6d13b0ca-043d-4d42-8c9d-3f3313ea3a00", all_alerts[0].workbook_id) + self.assertEqual("Safari stats", all_alerts[0].workbook_name) + self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", all_alerts[0].project_id) + self.assertEqual("Default", all_alerts[0].project_name) + + def test_get_by_id(self) -> None: response_xml = read_xml_asset(GET_BY_ID_XML) with requests_mock.mock() as m: - m.get(self.baseurl + '/5ea59b45-e497-5673-8809-bfe213236f75', text=response_xml) - alert = self.server.data_alerts.get_by_id('5ea59b45-e497-5673-8809-bfe213236f75') + m.get(self.baseurl + "/5ea59b45-e497-5673-8809-bfe213236f75", text=response_xml) + alert = self.server.data_alerts.get_by_id("5ea59b45-e497-5673-8809-bfe213236f75") self.assertTrue(isinstance(alert.recipients, list)) self.assertEqual(len(alert.recipients), 1) - self.assertEqual(alert.recipients[0], 'dd2239f6-ddf1-4107-981a-4cf94e415794') + self.assertEqual(alert.recipients[0], "dd2239f6-ddf1-4107-981a-4cf94e415794") - def test_update(self): + def test_update(self) -> None: response_xml = read_xml_asset(UPDATE_XML) with requests_mock.mock() as m: - m.put(self.baseurl + '/5ea59b45-e497-5673-8809-bfe213236f75', text=response_xml) + m.put(self.baseurl + "/5ea59b45-e497-5673-8809-bfe213236f75", text=response_xml) single_alert = TSC.DataAlertItem() - single_alert._id = '5ea59b45-e497-5673-8809-bfe213236f75' - single_alert._subject = 'Data Alert test' - single_alert._frequency = 'Daily' - single_alert._public = "true" + single_alert._id = "5ea59b45-e497-5673-8809-bfe213236f75" + single_alert._subject = "Data Alert test" + single_alert._frequency = "Daily" + single_alert._public = True single_alert._owner_id = "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" single_alert = self.server.data_alerts.update(single_alert) - self.assertEqual('5ea59b45-e497-5673-8809-bfe213236f75', single_alert.id) - self.assertEqual('Data Alert test', single_alert.subject) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', single_alert.creatorId) - self.assertEqual('2020-08-10T23:17:06Z', single_alert.createdAt) - self.assertEqual('2020-08-10T23:17:06Z', single_alert.updatedAt) - self.assertEqual('Daily', single_alert.frequency) - self.assertEqual('true', single_alert.public) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', single_alert.owner_id) - self.assertEqual('Bob', single_alert.owner_name) - self.assertEqual('d79634e1-6063-4ec9-95ff-50acbf609ff5', single_alert.view_id) - self.assertEqual('ENDANGERED SAFARI', single_alert.view_name) - self.assertEqual('6d13b0ca-043d-4d42-8c9d-3f3313ea3a00', single_alert.workbook_id) - self.assertEqual('Safari stats', single_alert.workbook_name) - self.assertEqual('5241e88d-d384-4fd7-9c2f-648b5247efc5', single_alert.project_id) - self.assertEqual('Default', single_alert.project_name) - - def test_add_user_to_alert(self): + self.assertEqual("5ea59b45-e497-5673-8809-bfe213236f75", single_alert.id) + self.assertEqual("Data Alert test", single_alert.subject) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_alert.creatorId) + self.assertEqual("2020-08-10T23:17:06Z", single_alert.createdAt) + self.assertEqual("2020-08-10T23:17:06Z", single_alert.updatedAt) + self.assertEqual("Daily", single_alert.frequency) + self.assertEqual("true", single_alert.public) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_alert.owner_id) + self.assertEqual("Bob", single_alert.owner_name) + self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", single_alert.view_id) + self.assertEqual("ENDANGERED SAFARI", single_alert.view_name) + self.assertEqual("6d13b0ca-043d-4d42-8c9d-3f3313ea3a00", single_alert.workbook_id) + self.assertEqual("Safari stats", single_alert.workbook_name) + self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", single_alert.project_id) + self.assertEqual("Default", single_alert.project_name) + + def test_add_user_to_alert(self) -> None: response_xml = read_xml_asset(ADD_USER_TO_ALERT) single_alert = TSC.DataAlertItem() - single_alert._id = '0448d2ed-590d-4fa0-b272-a2a8a24555b5' - in_user = TSC.UserItem('Bob', TSC.UserItem.Roles.Explorer) - in_user._id = '5de011f8-5aa9-4d5b-b991-f462c8dd6bb7' + single_alert._id = "0448d2ed-590d-4fa0-b272-a2a8a24555b5" + in_user = TSC.UserItem("Bob", TSC.UserItem.Roles.Explorer) + in_user._id = "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" with requests_mock.mock() as m: - m.post(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5/users', text=response_xml) + m.post(self.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5/users", text=response_xml) out_user = self.server.data_alerts.add_user_to_alert(single_alert, in_user) @@ -102,14 +99,14 @@ def test_add_user_to_alert(self): self.assertEqual(out_user.name, in_user.name) self.assertEqual(out_user.site_role, in_user.site_role) - def test_delete(self): + def test_delete(self) -> None: with requests_mock.mock() as m: - m.delete(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5', status_code=204) - self.server.data_alerts.delete('0448d2ed-590d-4fa0-b272-a2a8a24555b5') + m.delete(self.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5", status_code=204) + self.server.data_alerts.delete("0448d2ed-590d-4fa0-b272-a2a8a24555b5") - def test_delete_user_from_alert(self): - alert_id = '5ea59b45-e497-5673-8809-bfe213236f75' - user_id = '5de011f8-5aa9-4d5b-b991-f462c8dd6bb7' + def test_delete_user_from_alert(self) -> None: + alert_id = "5ea59b45-e497-5673-8809-bfe213236f75" + user_id = "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" with requests_mock.mock() as m: - m.delete(self.baseurl + '/{0}/users/{1}'.format(alert_id, user_id), status_code=204) + m.delete(self.baseurl + "/{0}/users/{1}".format(alert_id, user_id), status_code=204) self.server.data_alerts.delete_user_from_alert(alert_id, user_id) diff --git a/test/test_database.py b/test/test_database.py index e7c6a6fb6..3fd2c9a67 100644 --- a/test/test_database.py +++ b/test/test_database.py @@ -1,25 +1,22 @@ import unittest -import os + import requests_mock -import xml.etree.ElementTree as ET + import tableauserverclient as TSC -from tableauserverclient.datetime_helpers import format_datetime -from tableauserverclient.server.endpoint.exceptions import InternalServerError -from tableauserverclient.server.request_factory import RequestFactory -from ._utils import read_xml_asset, read_xml_assets, asset - -GET_XML = 'database_get.xml' -POPULATE_PERMISSIONS_XML = 'database_populate_permissions.xml' -UPDATE_XML = 'database_update.xml' +from ._utils import read_xml_asset, asset + +GET_XML = "database_get.xml" +POPULATE_PERMISSIONS_XML = "database_populate_permissions.xml" +UPDATE_XML = "database_update.xml" GET_DQW_BY_CONTENT = "dqw_by_content_type.xml" class DatabaseTests(unittest.TestCase): def setUp(self): - self.server = TSC.Server('http://test') + self.server = TSC.Server("http://test", False) # Fake signin - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.server.version = "3.5" self.baseurl = self.server.databases.baseurl @@ -31,64 +28,72 @@ def test_get(self): all_databases, pagination_item = self.server.databases.get() self.assertEqual(5, pagination_item.total_available) - self.assertEqual('5ea59b45-e497-4827-8809-bfe213236f75', all_databases[0].id) - self.assertEqual('hyper', all_databases[0].connection_type) - self.assertEqual('hyper_0.hyper', all_databases[0].name) - - self.assertEqual('23591f2c-4802-4d6a-9e28-574a8ea9bc4c', all_databases[1].id) - self.assertEqual('sqlserver', all_databases[1].connection_type) - self.assertEqual('testv1', all_databases[1].name) - self.assertEqual('9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0', all_databases[1].contact_id) + self.assertEqual("5ea59b45-e497-4827-8809-bfe213236f75", all_databases[0].id) + self.assertEqual("hyper", all_databases[0].connection_type) + self.assertEqual("hyper_0.hyper", all_databases[0].name) + + self.assertEqual("23591f2c-4802-4d6a-9e28-574a8ea9bc4c", all_databases[1].id) + self.assertEqual("sqlserver", all_databases[1].connection_type) + self.assertEqual("testv1", all_databases[1].name) + self.assertEqual("9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0", all_databases[1].contact_id) self.assertEqual(True, all_databases[1].certified) def test_update(self): response_xml = read_xml_asset(UPDATE_XML) with requests_mock.mock() as m: - m.put(self.baseurl + '/23591f2c-4802-4d6a-9e28-574a8ea9bc4c', text=response_xml) - single_database = TSC.DatabaseItem('test') - single_database.contact_id = '9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0' - single_database._id = '23591f2c-4802-4d6a-9e28-574a8ea9bc4c' + m.put(self.baseurl + "/23591f2c-4802-4d6a-9e28-574a8ea9bc4c", text=response_xml) + single_database = TSC.DatabaseItem("test") + single_database.contact_id = "9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0" + single_database._id = "23591f2c-4802-4d6a-9e28-574a8ea9bc4c" single_database.certified = True single_database.certification_note = "Test" single_database = self.server.databases.update(single_database) - self.assertEqual('23591f2c-4802-4d6a-9e28-574a8ea9bc4c', single_database.id) - self.assertEqual('9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0', single_database.contact_id) + self.assertEqual("23591f2c-4802-4d6a-9e28-574a8ea9bc4c", single_database.id) + self.assertEqual("9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0", single_database.contact_id) self.assertEqual(True, single_database.certified) self.assertEqual("Test", single_database.certification_note) def test_populate_permissions(self): - with open(asset(POPULATE_PERMISSIONS_XML), 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(asset(POPULATE_PERMISSIONS_XML), "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions', text=response_xml) - single_database = TSC.DatabaseItem('test') - single_database._id = '0448d2ed-590d-4fa0-b272-a2a8a24555b5' + m.get(self.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions", text=response_xml) + single_database = TSC.DatabaseItem("test") + single_database._id = "0448d2ed-590d-4fa0-b272-a2a8a24555b5" self.server.databases.populate_permissions(single_database) permissions = single_database.permissions - self.assertEqual(permissions[0].grantee.tag_name, 'group') - self.assertEqual(permissions[0].grantee.id, '5e5e1978-71fa-11e4-87dd-7382f5c437af') - self.assertDictEqual(permissions[0].capabilities, { - TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Deny, - TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, - }) - - self.assertEqual(permissions[1].grantee.tag_name, 'user') - self.assertEqual(permissions[1].grantee.id, '7c37ee24-c4b1-42b6-a154-eaeab7ee330a') - self.assertDictEqual(permissions[1].capabilities, { - TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, - }) + self.assertEqual(permissions[0].grantee.tag_name, "group") + self.assertEqual(permissions[0].grantee.id, "5e5e1978-71fa-11e4-87dd-7382f5c437af") + self.assertDictEqual( + permissions[0].capabilities, + { + TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + }, + ) + + self.assertEqual(permissions[1].grantee.tag_name, "user") + self.assertEqual(permissions[1].grantee.id, "7c37ee24-c4b1-42b6-a154-eaeab7ee330a") + self.assertDictEqual( + permissions[1].capabilities, + { + TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, + }, + ) def test_populate_data_quality_warning(self): - with open(asset(GET_DQW_BY_CONTENT), 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(asset(GET_DQW_BY_CONTENT), "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.server.databases._data_quality_warnings.baseurl + '/94441d26-9a52-4a42-b0fb-3f94792d1aac', - text=response_xml) - single_database = TSC.DatabaseItem('test') - single_database._id = '94441d26-9a52-4a42-b0fb-3f94792d1aac' + m.get( + self.server.databases._data_quality_warnings.baseurl + "/94441d26-9a52-4a42-b0fb-3f94792d1aac", + text=response_xml, + ) + single_database = TSC.DatabaseItem("test") + single_database._id = "94441d26-9a52-4a42-b0fb-3f94792d1aac" self.server.databases.populate_dqw(single_database) dqws = single_database.dqws @@ -104,5 +109,5 @@ def test_populate_data_quality_warning(self): def test_delete(self): with requests_mock.mock() as m: - m.delete(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5', status_code=204) - self.server.databases.delete('0448d2ed-590d-4fa0-b272-a2a8a24555b5') + m.delete(self.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5", status_code=204) + self.server.databases.delete("0448d2ed-590d-4fa0-b272-a2a8a24555b5") diff --git a/test/test_datasource.py b/test/test_datasource.py index 52a5eabe3..46378201f 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -1,85 +1,87 @@ -from tableauserverclient.server.endpoint.fileuploads_endpoint import Fileuploads +import os +import tempfile import unittest from io import BytesIO -import os -import requests_mock -import xml.etree.ElementTree as ET from zipfile import ZipFile +import requests_mock +from defusedxml.ElementTree import fromstring + import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime from tableauserverclient.server.endpoint.exceptions import InternalServerError +from tableauserverclient.server.endpoint.fileuploads_endpoint import Fileuploads from tableauserverclient.server.request_factory import RequestFactory from ._utils import read_xml_asset, read_xml_assets, asset - -ADD_TAGS_XML = 'datasource_add_tags.xml' -GET_XML = 'datasource_get.xml' -GET_EMPTY_XML = 'datasource_get_empty.xml' -GET_BY_ID_XML = 'datasource_get_by_id.xml' -POPULATE_CONNECTIONS_XML = 'datasource_populate_connections.xml' -POPULATE_PERMISSIONS_XML = 'datasource_populate_permissions.xml' -PUBLISH_XML = 'datasource_publish.xml' -PUBLISH_XML_ASYNC = 'datasource_publish_async.xml' -REFRESH_XML = 'datasource_refresh.xml' -UPDATE_XML = 'datasource_update.xml' -UPDATE_HYPER_DATA_XML = 'datasource_data_update.xml' -UPDATE_CONNECTION_XML = 'datasource_connection_update.xml' +ADD_TAGS_XML = "datasource_add_tags.xml" +GET_XML = "datasource_get.xml" +GET_EMPTY_XML = "datasource_get_empty.xml" +GET_BY_ID_XML = "datasource_get_by_id.xml" +POPULATE_CONNECTIONS_XML = "datasource_populate_connections.xml" +POPULATE_PERMISSIONS_XML = "datasource_populate_permissions.xml" +PUBLISH_XML = "datasource_publish.xml" +PUBLISH_XML_ASYNC = "datasource_publish_async.xml" +REFRESH_XML = "datasource_refresh.xml" +REVISION_XML = "datasource_revision.xml" +UPDATE_XML = "datasource_update.xml" +UPDATE_HYPER_DATA_XML = "datasource_data_update.xml" +UPDATE_CONNECTION_XML = "datasource_connection_update.xml" class DatasourceTests(unittest.TestCase): - def setUp(self): - self.server = TSC.Server('http://test') + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) # Fake signin - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.baseurl = self.server.datasources.baseurl - def test_get(self): + def test_get(self) -> None: response_xml = read_xml_asset(GET_XML) with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) all_datasources, pagination_item = self.server.datasources.get() self.assertEqual(2, pagination_item.total_available) - self.assertEqual('e76a1461-3b1d-4588-bf1b-17551a879ad9', all_datasources[0].id) - 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('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) - self.assertEqual('SampleDS', all_datasources[0].name) - self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', all_datasources[0].project_id) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_datasources[0].owner_id) - self.assertEqual('https://web.com', all_datasources[0].webpage_url) + self.assertEqual("e76a1461-3b1d-4588-bf1b-17551a879ad9", all_datasources[0].id) + 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("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) + self.assertEqual("SampleDS", all_datasources[0].name) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", all_datasources[0].project_id) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_datasources[0].owner_id) + self.assertEqual("https://web.com", all_datasources[0].webpage_url) self.assertFalse(all_datasources[0].encrypt_extracts) self.assertTrue(all_datasources[0].has_extracts) self.assertFalse(all_datasources[0].use_remote_query_agent) - self.assertEqual('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', all_datasources[1].id) - 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('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) - self.assertEqual('Sample datasource', all_datasources[1].name) - self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', all_datasources[1].project_id) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_datasources[1].owner_id) - self.assertEqual(set(['world', 'indicators', 'sample']), all_datasources[1].tags) - self.assertEqual('https://page.com', all_datasources[1].webpage_url) + self.assertEqual("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", all_datasources[1].id) + 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("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) + self.assertEqual("Sample datasource", all_datasources[1].name) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", all_datasources[1].project_id) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_datasources[1].owner_id) + self.assertEqual(set(["world", "indicators", "sample"]), all_datasources[1].tags) + self.assertEqual("https://page.com", all_datasources[1].webpage_url) self.assertTrue(all_datasources[1].encrypt_extracts) self.assertFalse(all_datasources[1].has_extracts) self.assertTrue(all_datasources[1].use_remote_query_agent) - def test_get_before_signin(self): + def test_get_before_signin(self) -> None: self.server._auth_token = None self.assertRaises(TSC.NotSignedInError, self.server.datasources.get) - def test_get_empty(self): + def test_get_empty(self) -> None: response_xml = read_xml_asset(GET_EMPTY_XML) with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) @@ -88,33 +90,33 @@ def test_get_empty(self): self.assertEqual(0, pagination_item.total_available) self.assertEqual([], all_datasources) - def test_get_by_id(self): + def test_get_by_id(self) -> None: response_xml = read_xml_asset(GET_BY_ID_XML) with requests_mock.mock() as m: - m.get(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', text=response_xml) - single_datasource = self.server.datasources.get_by_id('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb') - - self.assertEqual('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', single_datasource.id) - self.assertEqual('dataengine', single_datasource.datasource_type) - self.assertEqual('abc description xyz', single_datasource.description) - self.assertEqual('Sampledatasource', single_datasource.content_url) - self.assertEqual('2016-08-04T21:31:55Z', format_datetime(single_datasource.created_at)) - self.assertEqual('2016-08-04T21:31:55Z', format_datetime(single_datasource.updated_at)) - self.assertEqual('default', single_datasource.project_name) - self.assertEqual('Sample datasource', single_datasource.name) - self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', single_datasource.project_id) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', single_datasource.owner_id) - self.assertEqual(set(['world', 'indicators', 'sample']), single_datasource.tags) + m.get(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", text=response_xml) + single_datasource = self.server.datasources.get_by_id("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb") + + self.assertEqual("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", single_datasource.id) + self.assertEqual("dataengine", single_datasource.datasource_type) + self.assertEqual("abc description xyz", single_datasource.description) + self.assertEqual("Sampledatasource", single_datasource.content_url) + self.assertEqual("2016-08-04T21:31:55Z", format_datetime(single_datasource.created_at)) + self.assertEqual("2016-08-04T21:31:55Z", format_datetime(single_datasource.updated_at)) + self.assertEqual("default", single_datasource.project_name) + self.assertEqual("Sample datasource", single_datasource.name) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", single_datasource.project_id) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_datasource.owner_id) + self.assertEqual(set(["world", "indicators", "sample"]), single_datasource.tags) self.assertEqual(TSC.DatasourceItem.AskDataEnablement.SiteDefault, single_datasource.ask_data_enablement) - def test_update(self): + def test_update(self) -> None: response_xml = read_xml_asset(UPDATE_XML) with requests_mock.mock() as m: - m.put(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', text=response_xml) - single_datasource = TSC.DatasourceItem('1d0304cd-3796-429f-b815-7258370b9b74', 'Sample datasource') - single_datasource.owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' - single_datasource._content_url = 'Sampledatasource' - single_datasource._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' + m.put(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", text=response_xml) + single_datasource = TSC.DatasourceItem("1d0304cd-3796-429f-b815-7258370b9b74", "Sample datasource") + single_datasource.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + single_datasource._content_url = "Sampledatasource" + single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" single_datasource.certified = True single_datasource.certification_note = "Warning, here be dragons." updated_datasource = self.server.datasources.update(single_datasource) @@ -127,476 +129,557 @@ def test_update(self): self.assertEqual(updated_datasource.certified, single_datasource.certified) self.assertEqual(updated_datasource.certification_note, single_datasource.certification_note) - def test_update_copy_fields(self): - with open(asset(UPDATE_XML), 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_update_copy_fields(self) -> None: + with open(asset(UPDATE_XML), "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.put(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', text=response_xml) - single_datasource = TSC.DatasourceItem('1d0304cd-3796-429f-b815-7258370b9b74', 'test') - single_datasource._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' - single_datasource._project_name = 'Tester' + m.put(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", text=response_xml) + single_datasource = TSC.DatasourceItem("1d0304cd-3796-429f-b815-7258370b9b74", "test") + single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + single_datasource._project_name = "Tester" updated_datasource = self.server.datasources.update(single_datasource) self.assertEqual(single_datasource.tags, updated_datasource.tags) self.assertEqual(single_datasource._project_name, updated_datasource._project_name) - def test_update_tags(self): + def test_update_tags(self) -> None: add_tags_xml, update_xml = read_xml_assets(ADD_TAGS_XML, UPDATE_XML) with requests_mock.mock() as m: - m.put(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags', text=add_tags_xml) - m.delete(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags/b', status_code=204) - m.delete(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags/d', status_code=204) - m.put(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', text=update_xml) - single_datasource = TSC.DatasourceItem('1d0304cd-3796-429f-b815-7258370b9b74') - single_datasource._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' - single_datasource._initial_tags.update(['a', 'b', 'c', 'd']) - single_datasource.tags.update(['a', 'c', 'e']) + m.put(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags", text=add_tags_xml) + m.delete(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags/b", status_code=204) + m.delete(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags/d", status_code=204) + m.put(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", text=update_xml) + single_datasource = TSC.DatasourceItem("1d0304cd-3796-429f-b815-7258370b9b74") + single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + single_datasource._initial_tags.update(["a", "b", "c", "d"]) + single_datasource.tags.update(["a", "c", "e"]) updated_datasource = self.server.datasources.update(single_datasource) self.assertEqual(single_datasource.tags, updated_datasource.tags) self.assertEqual(single_datasource._initial_tags, updated_datasource._initial_tags) - def test_populate_connections(self): + def test_populate_connections(self) -> None: response_xml = read_xml_asset(POPULATE_CONNECTIONS_XML) with requests_mock.mock() as m: - m.get(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections', text=response_xml) - single_datasource = TSC.DatasourceItem('1d0304cd-3796-429f-b815-7258370b9b74', 'test') - single_datasource.owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' - single_datasource._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' + m.get(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections", text=response_xml) + single_datasource = TSC.DatasourceItem("1d0304cd-3796-429f-b815-7258370b9b74", "test") + single_datasource.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + 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) + self.assertEqual("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", single_datasource.id) connections = single_datasource.connections self.assertTrue(connections) ds1, ds2 = connections - self.assertEqual('be786ae0-d2bf-4a4b-9b34-e2de8d2d4488', ds1.id) - self.assertEqual('textscan', ds1.connection_type) - self.assertEqual('forty-two.net', ds1.server_address) - self.assertEqual('duo', ds1.username) + self.assertEqual("be786ae0-d2bf-4a4b-9b34-e2de8d2d4488", ds1.id) + self.assertEqual("textscan", ds1.connection_type) + self.assertEqual("forty-two.net", ds1.server_address) + self.assertEqual("duo", ds1.username) self.assertEqual(True, ds1.embed_password) - self.assertEqual('970e24bc-e200-4841-a3e9-66e7d122d77e', ds2.id) - self.assertEqual('sqlserver', ds2.connection_type) - self.assertEqual('database.com', ds2.server_address) - self.assertEqual('heero', ds2.username) + self.assertEqual("970e24bc-e200-4841-a3e9-66e7d122d77e", ds2.id) + self.assertEqual("sqlserver", ds2.connection_type) + self.assertEqual("database.com", ds2.server_address) + self.assertEqual("heero", ds2.username) self.assertEqual(False, ds2.embed_password) - def test_update_connection(self): + def test_update_connection(self) -> None: populate_xml, response_xml = read_xml_assets(POPULATE_CONNECTIONS_XML, UPDATE_CONNECTION_XML) with requests_mock.mock() as m: - m.get(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections', text=populate_xml) - m.put(self.baseurl + - '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections/be786ae0-d2bf-4a4b-9b34-e2de8d2d4488', - text=response_xml) - single_datasource = TSC.DatasourceItem('1d0304cd-3796-429f-b815-7258370b9b74') - single_datasource.owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' - single_datasource._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' + m.get(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections", text=populate_xml) + m.put( + self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections/be786ae0-d2bf-4a4b-9b34-e2de8d2d4488", + text=response_xml, + ) + single_datasource = TSC.DatasourceItem("1d0304cd-3796-429f-b815-7258370b9b74") + single_datasource.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" self.server.datasources.populate_connections(single_datasource) - connection = single_datasource.connections[0] - connection.server_address = 'bar' - connection.server_port = '9876' - connection.username = 'foo' + connection = single_datasource.connections[0] # type: ignore[index] + connection.server_address = "bar" + connection.server_port = "9876" + connection.username = "foo" new_connection = self.server.datasources.update_connection(single_datasource, connection) self.assertEqual(connection.id, new_connection.id) self.assertEqual(connection.connection_type, new_connection.connection_type) - self.assertEqual('bar', new_connection.server_address) - self.assertEqual('9876', new_connection.server_port) - self.assertEqual('foo', new_connection.username) + self.assertEqual("bar", new_connection.server_address) + self.assertEqual("9876", new_connection.server_port) + self.assertEqual("foo", new_connection.username) - def test_populate_permissions(self): - with open(asset(POPULATE_PERMISSIONS_XML), 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_populate_permissions(self) -> None: + with open(asset(POPULATE_PERMISSIONS_XML), "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions', text=response_xml) - single_datasource = TSC.DatasourceItem('1d0304cd-3796-429f-b815-7258370b9b74', 'test') - single_datasource._id = '0448d2ed-590d-4fa0-b272-a2a8a24555b5' + m.get(self.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions", text=response_xml) + single_datasource = TSC.DatasourceItem("1d0304cd-3796-429f-b815-7258370b9b74", "test") + single_datasource._id = "0448d2ed-590d-4fa0-b272-a2a8a24555b5" self.server.datasources.populate_permissions(single_datasource) permissions = single_datasource.permissions - self.assertEqual(permissions[0].grantee.tag_name, 'group') - self.assertEqual(permissions[0].grantee.id, '5e5e1978-71fa-11e4-87dd-7382f5c437af') - self.assertDictEqual(permissions[0].capabilities, { - TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, - TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Deny, - TSC.Permission.Capability.Connect: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, - }) - - self.assertEqual(permissions[1].grantee.tag_name, 'user') - self.assertEqual(permissions[1].grantee.id, '7c37ee24-c4b1-42b6-a154-eaeab7ee330a') - self.assertDictEqual(permissions[1].capabilities, { - TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, - }) - - def test_publish(self): + self.assertEqual(permissions[0].grantee.tag_name, "group") # type: ignore[index] + self.assertEqual(permissions[0].grantee.id, "5e5e1978-71fa-11e4-87dd-7382f5c437af") # type: ignore[index] + self.assertDictEqual( + permissions[0].capabilities, # type: ignore[index] + { + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.Connect: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + }, + ) + + self.assertEqual(permissions[1].grantee.tag_name, "user") # type: ignore[index] + self.assertEqual(permissions[1].grantee.id, "7c37ee24-c4b1-42b6-a154-eaeab7ee330a") # type: ignore[index] + self.assertDictEqual( + permissions[1].capabilities, # type: ignore[index] + { + TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, + }, + ) + + def test_publish(self) -> None: response_xml = read_xml_asset(PUBLISH_XML) with requests_mock.mock() as m: m.post(self.baseurl, text=response_xml) - new_datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'SampleDS') + new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "SampleDS") publish_mode = self.server.PublishMode.CreateNew - new_datasource = self.server.datasources.publish(new_datasource, - asset('SampleDS.tds'), - mode=publish_mode) - - self.assertEqual('e76a1461-3b1d-4588-bf1b-17551a879ad9', new_datasource.id) - self.assertEqual('SampleDS', new_datasource.name) - self.assertEqual('SampleDS', new_datasource.content_url) - self.assertEqual('dataengine', new_datasource.datasource_type) - self.assertEqual('2016-08-11T21:22:40Z', format_datetime(new_datasource.created_at)) - self.assertEqual('2016-08-17T23:37:08Z', format_datetime(new_datasource.updated_at)) - self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', new_datasource.project_id) - self.assertEqual('default', new_datasource.project_name) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', new_datasource.owner_id) - - def test_publish_a_non_packaged_file_object(self): + new_datasource = self.server.datasources.publish(new_datasource, asset("SampleDS.tds"), mode=publish_mode) + + self.assertEqual("e76a1461-3b1d-4588-bf1b-17551a879ad9", new_datasource.id) + self.assertEqual("SampleDS", new_datasource.name) + self.assertEqual("SampleDS", new_datasource.content_url) + self.assertEqual("dataengine", new_datasource.datasource_type) + self.assertEqual("2016-08-11T21:22:40Z", format_datetime(new_datasource.created_at)) + self.assertEqual("2016-08-17T23:37:08Z", format_datetime(new_datasource.updated_at)) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", new_datasource.project_id) + self.assertEqual("default", new_datasource.project_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", new_datasource.owner_id) + + def test_publish_a_non_packaged_file_object(self) -> None: response_xml = read_xml_asset(PUBLISH_XML) with requests_mock.mock() as m: m.post(self.baseurl, text=response_xml) - new_datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'SampleDS') + new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "SampleDS") publish_mode = self.server.PublishMode.CreateNew - with open(asset('SampleDS.tds'), 'rb') as file_object: - new_datasource = self.server.datasources.publish(new_datasource, - file_object, - mode=publish_mode) - - self.assertEqual('e76a1461-3b1d-4588-bf1b-17551a879ad9', new_datasource.id) - self.assertEqual('SampleDS', new_datasource.name) - self.assertEqual('SampleDS', new_datasource.content_url) - self.assertEqual('dataengine', new_datasource.datasource_type) - self.assertEqual('2016-08-11T21:22:40Z', format_datetime(new_datasource.created_at)) - self.assertEqual('2016-08-17T23:37:08Z', format_datetime(new_datasource.updated_at)) - self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', new_datasource.project_id) - self.assertEqual('default', new_datasource.project_name) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', new_datasource.owner_id) - - def test_publish_a_packaged_file_object(self): + with open(asset("SampleDS.tds"), "rb") as file_object: + new_datasource = self.server.datasources.publish(new_datasource, file_object, mode=publish_mode) + + self.assertEqual("e76a1461-3b1d-4588-bf1b-17551a879ad9", new_datasource.id) + self.assertEqual("SampleDS", new_datasource.name) + self.assertEqual("SampleDS", new_datasource.content_url) + self.assertEqual("dataengine", new_datasource.datasource_type) + self.assertEqual("2016-08-11T21:22:40Z", format_datetime(new_datasource.created_at)) + self.assertEqual("2016-08-17T23:37:08Z", format_datetime(new_datasource.updated_at)) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", new_datasource.project_id) + self.assertEqual("default", new_datasource.project_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", new_datasource.owner_id) + + def test_publish_a_packaged_file_object(self) -> None: response_xml = read_xml_asset(PUBLISH_XML) with requests_mock.mock() as m: m.post(self.baseurl, text=response_xml) - new_datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'SampleDS') + new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "SampleDS") publish_mode = self.server.PublishMode.CreateNew # Create a dummy tdsx file in memory with BytesIO() as zip_archive: - with ZipFile(zip_archive, 'w') as zf: - zf.write(asset('SampleDS.tds')) + with ZipFile(zip_archive, "w") as zf: + zf.write(asset("SampleDS.tds")) zip_archive.seek(0) - new_datasource = self.server.datasources.publish(new_datasource, - zip_archive, - mode=publish_mode) - - self.assertEqual('e76a1461-3b1d-4588-bf1b-17551a879ad9', new_datasource.id) - self.assertEqual('SampleDS', new_datasource.name) - self.assertEqual('SampleDS', new_datasource.content_url) - self.assertEqual('dataengine', new_datasource.datasource_type) - self.assertEqual('2016-08-11T21:22:40Z', format_datetime(new_datasource.created_at)) - self.assertEqual('2016-08-17T23:37:08Z', format_datetime(new_datasource.updated_at)) - self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', new_datasource.project_id) - self.assertEqual('default', new_datasource.project_name) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', new_datasource.owner_id) - - def test_publish_async(self): + new_datasource = self.server.datasources.publish(new_datasource, zip_archive, mode=publish_mode) + + self.assertEqual("e76a1461-3b1d-4588-bf1b-17551a879ad9", new_datasource.id) + self.assertEqual("SampleDS", new_datasource.name) + self.assertEqual("SampleDS", new_datasource.content_url) + self.assertEqual("dataengine", new_datasource.datasource_type) + self.assertEqual("2016-08-11T21:22:40Z", format_datetime(new_datasource.created_at)) + self.assertEqual("2016-08-17T23:37:08Z", format_datetime(new_datasource.updated_at)) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", new_datasource.project_id) + self.assertEqual("default", new_datasource.project_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", new_datasource.owner_id) + + def test_publish_async(self) -> None: self.server.version = "3.0" baseurl = self.server.datasources.baseurl response_xml = read_xml_asset(PUBLISH_XML_ASYNC) with requests_mock.mock() as m: m.post(baseurl, text=response_xml) - new_datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'SampleDS') + new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "SampleDS") publish_mode = self.server.PublishMode.CreateNew - new_job = self.server.datasources.publish(new_datasource, - asset('SampleDS.tds'), - mode=publish_mode, - as_job=True) + new_job = self.server.datasources.publish( + new_datasource, asset("SampleDS.tds"), mode=publish_mode, as_job=True + ) - self.assertEqual('9a373058-af5f-4f83-8662-98b3e0228a73', new_job.id) - self.assertEqual('PublishDatasource', new_job.type) - self.assertEqual('0', new_job.progress) - self.assertEqual('2018-06-30T00:54:54Z', format_datetime(new_job.created_at)) + self.assertEqual("9a373058-af5f-4f83-8662-98b3e0228a73", new_job.id) + self.assertEqual("PublishDatasource", new_job.type) + self.assertEqual("0", new_job.progress) + self.assertEqual("2018-06-30T00:54:54Z", format_datetime(new_job.created_at)) self.assertEqual(1, new_job.finish_code) - def test_publish_unnamed_file_object(self): - new_datasource = TSC.DatasourceItem('test') + def test_publish_unnamed_file_object(self) -> None: + new_datasource = TSC.DatasourceItem("test") publish_mode = self.server.PublishMode.CreateNew - with open(asset('SampleDS.tds'), 'rb') as file_object: - self.assertRaises(ValueError, self.server.datasources.publish, - new_datasource, file_object, publish_mode - ) + with open(asset("SampleDS.tds"), "rb") as file_object: + self.assertRaises(ValueError, self.server.datasources.publish, new_datasource, file_object, publish_mode) - def test_refresh_id(self): - self.server.version = '2.8' + def test_refresh_id(self) -> None: + self.server.version = "2.8" self.baseurl = self.server.datasources.baseurl response_xml = read_xml_asset(REFRESH_XML) with requests_mock.mock() as m: - m.post(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/refresh', - status_code=202, text=response_xml) - new_job = self.server.datasources.refresh('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb') + m.post(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/refresh", status_code=202, text=response_xml) + new_job = self.server.datasources.refresh("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb") - self.assertEqual('7c3d599e-949f-44c3-94a1-f30ba85757e4', new_job.id) - self.assertEqual('RefreshExtract', new_job.type) + self.assertEqual("7c3d599e-949f-44c3-94a1-f30ba85757e4", new_job.id) + self.assertEqual("RefreshExtract", new_job.type) self.assertEqual(None, new_job.progress) - self.assertEqual('2020-03-05T22:05:32Z', format_datetime(new_job.created_at)) + self.assertEqual("2020-03-05T22:05:32Z", format_datetime(new_job.created_at)) self.assertEqual(-1, new_job.finish_code) - def test_refresh_object(self): - self.server.version = '2.8' + def test_refresh_object(self) -> None: + self.server.version = "2.8" self.baseurl = self.server.datasources.baseurl - datasource = TSC.DatasourceItem('') - datasource._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' + datasource = TSC.DatasourceItem("") + datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" response_xml = read_xml_asset(REFRESH_XML) with requests_mock.mock() as m: - m.post(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/refresh', - status_code=202, text=response_xml) + m.post(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/refresh", status_code=202, text=response_xml) new_job = self.server.datasources.refresh(datasource) # We only check the `id`; remaining fields are already tested in `test_refresh_id` - self.assertEqual('7c3d599e-949f-44c3-94a1-f30ba85757e4', new_job.id) + self.assertEqual("7c3d599e-949f-44c3-94a1-f30ba85757e4", new_job.id) - def test_update_hyper_data_datasource_object(self): + def test_update_hyper_data_datasource_object(self) -> None: """Calling `update_hyper_data` with a `DatasourceItem` should update that datasource""" self.server.version = "3.13" self.baseurl = self.server.datasources.baseurl - datasource = TSC.DatasourceItem('') - datasource._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' + datasource = TSC.DatasourceItem("") + datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" response_xml = read_xml_asset(UPDATE_HYPER_DATA_XML) with requests_mock.mock() as m: - m.patch(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/data', - status_code=202, headers={"requestid": "test_id"}, text=response_xml) + m.patch( + self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/data", + status_code=202, + headers={"requestid": "test_id"}, + text=response_xml, + ) new_job = self.server.datasources.update_hyper_data(datasource, request_id="test_id", actions=[]) - self.assertEqual('5c0ba560-c959-424e-b08a-f32ef0bfb737', new_job.id) - self.assertEqual('UpdateUploadedFile', new_job.type) + self.assertEqual("5c0ba560-c959-424e-b08a-f32ef0bfb737", new_job.id) + self.assertEqual("UpdateUploadedFile", new_job.type) self.assertEqual(None, new_job.progress) - self.assertEqual('2021-09-18T09:40:12Z', format_datetime(new_job.created_at)) + self.assertEqual("2021-09-18T09:40:12Z", format_datetime(new_job.created_at)) self.assertEqual(-1, new_job.finish_code) - def test_update_hyper_data_connection_object(self): + def test_update_hyper_data_connection_object(self) -> None: """Calling `update_hyper_data` with a `ConnectionItem` should update that connection""" self.server.version = "3.13" self.baseurl = self.server.datasources.baseurl connection = TSC.ConnectionItem() - connection._datasource_id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' - connection._id = '7ecaccd8-39b0-4875-a77d-094f6e930019' + connection._datasource_id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + connection._id = "7ecaccd8-39b0-4875-a77d-094f6e930019" response_xml = read_xml_asset(UPDATE_HYPER_DATA_XML) with requests_mock.mock() as m: - m.patch(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections/7ecaccd8-39b0-4875-a77d-094f6e930019/data', - status_code=202, headers={"requestid": "test_id"}, text=response_xml) + m.patch( + self.baseurl + + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections/7ecaccd8-39b0-4875-a77d-094f6e930019/data", + status_code=202, + headers={"requestid": "test_id"}, + text=response_xml, + ) new_job = self.server.datasources.update_hyper_data(connection, request_id="test_id", actions=[]) # We only check the `id`; remaining fields are already tested in `test_update_hyper_data_datasource_object` - self.assertEqual('5c0ba560-c959-424e-b08a-f32ef0bfb737', new_job.id) + self.assertEqual("5c0ba560-c959-424e-b08a-f32ef0bfb737", new_job.id) - def test_update_hyper_data_datasource_string(self): + def test_update_hyper_data_datasource_string(self) -> None: """For convenience, calling `update_hyper_data` with a `str` should update the datasource with the corresponding UUID""" self.server.version = "3.13" self.baseurl = self.server.datasources.baseurl - datasource_id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' + datasource_id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" response_xml = read_xml_asset(UPDATE_HYPER_DATA_XML) with requests_mock.mock() as m: - m.patch(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/data', - status_code=202, headers={"requestid": "test_id"}, text=response_xml) + m.patch( + self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/data", + status_code=202, + headers={"requestid": "test_id"}, + text=response_xml, + ) new_job = self.server.datasources.update_hyper_data(datasource_id, request_id="test_id", actions=[]) # We only check the `id`; remaining fields are already tested in `test_update_hyper_data_datasource_object` - self.assertEqual('5c0ba560-c959-424e-b08a-f32ef0bfb737', new_job.id) + self.assertEqual("5c0ba560-c959-424e-b08a-f32ef0bfb737", new_job.id) - def test_update_hyper_data_datasource_payload_file(self): + def test_update_hyper_data_datasource_payload_file(self) -> None: """If `payload` is present, we upload it and associate the job with it""" self.server.version = "3.13" self.baseurl = self.server.datasources.baseurl - datasource_id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' - mock_upload_id = '10051:c3e56879876842d4b3600f20c1f79876-0:0' + datasource_id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + mock_upload_id = "10051:c3e56879876842d4b3600f20c1f79876-0:0" response_xml = read_xml_asset(UPDATE_HYPER_DATA_XML) - with requests_mock.mock() as rm, \ - unittest.mock.patch.object(Fileuploads, "upload", return_value=mock_upload_id): - rm.patch(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/data?uploadSessionId=' + mock_upload_id, - status_code=202, headers={"requestid": "test_id"}, text=response_xml) - new_job = self.server.datasources.update_hyper_data(datasource_id, request_id="test_id", - actions=[], payload=asset('World Indicators.hyper')) + with requests_mock.mock() as rm, unittest.mock.patch.object(Fileuploads, "upload", return_value=mock_upload_id): + rm.patch( + self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/data?uploadSessionId=" + mock_upload_id, + status_code=202, + headers={"requestid": "test_id"}, + text=response_xml, + ) + new_job = self.server.datasources.update_hyper_data( + datasource_id, request_id="test_id", actions=[], payload=asset("World Indicators.hyper") + ) # We only check the `id`; remaining fields are already tested in `test_update_hyper_data_datasource_object` - self.assertEqual('5c0ba560-c959-424e-b08a-f32ef0bfb737', new_job.id) + self.assertEqual("5c0ba560-c959-424e-b08a-f32ef0bfb737", new_job.id) - def test_update_hyper_data_datasource_invalid_payload_file(self): + def test_update_hyper_data_datasource_invalid_payload_file(self) -> None: """If `payload` points to a non-existing file, we report an error""" self.server.version = "3.13" self.baseurl = self.server.datasources.baseurl - datasource_id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' + datasource_id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" with self.assertRaises(IOError) as cm: - self.server.datasources.update_hyper_data(datasource_id, request_id="test_id", - actions=[], payload='no/such/file.missing') + self.server.datasources.update_hyper_data( + datasource_id, request_id="test_id", actions=[], payload="no/such/file.missing" + ) exception = cm.exception self.assertEqual(str(exception), "File path does not lead to an existing file.") - def test_delete(self): + def test_delete(self) -> None: with requests_mock.mock() as m: - m.delete(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', status_code=204) - self.server.datasources.delete('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb') + m.delete(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", status_code=204) + self.server.datasources.delete("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb") - def test_download(self): + def test_download(self) -> None: with requests_mock.mock() as m: - m.get(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content', - headers={'Content-Disposition': 'name="tableau_datasource"; filename="Sample datasource.tds"'}) - file_path = self.server.datasources.download('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb') + m.get( + self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content", + headers={"Content-Disposition": 'name="tableau_datasource"; filename="Sample datasource.tds"'}, + ) + file_path = self.server.datasources.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb") self.assertTrue(os.path.exists(file_path)) os.remove(file_path) - def test_download_sanitizes_name(self): + def test_download_sanitizes_name(self) -> None: filename = "Name,With,Commas.tds" disposition = 'name="tableau_workbook"; filename="{}"'.format(filename) with requests_mock.mock() as m: - m.get(self.baseurl + '/1f951daf-4061-451a-9df1-69a8062664f2/content', - headers={'Content-Disposition': disposition}) - file_path = self.server.datasources.download('1f951daf-4061-451a-9df1-69a8062664f2') + m.get( + self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", + headers={"Content-Disposition": disposition}, + ) + file_path = self.server.datasources.download("1f951daf-4061-451a-9df1-69a8062664f2") self.assertEqual(os.path.basename(file_path), "NameWithCommas.tds") self.assertTrue(os.path.exists(file_path)) os.remove(file_path) - def test_download_extract_only(self): + def test_download_extract_only(self) -> None: # Pretend we're 2.5 for 'extract_only' self.server.version = "2.5" self.baseurl = self.server.datasources.baseurl with requests_mock.mock() as m: - m.get(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content?includeExtract=False', - headers={'Content-Disposition': 'name="tableau_datasource"; filename="Sample datasource.tds"'}, - complete_qs=True) - file_path = self.server.datasources.download('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', include_extract=False) + m.get( + self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content?includeExtract=False", + headers={"Content-Disposition": 'name="tableau_datasource"; filename="Sample datasource.tds"'}, + complete_qs=True, + ) + file_path = self.server.datasources.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", include_extract=False) self.assertTrue(os.path.exists(file_path)) os.remove(file_path) - def test_update_missing_id(self): - single_datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'test') + def test_update_missing_id(self) -> None: + single_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "test") self.assertRaises(TSC.MissingRequiredFieldError, self.server.datasources.update, single_datasource) - def test_publish_missing_path(self): - new_datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'test') - self.assertRaises(IOError, self.server.datasources.publish, new_datasource, - '', self.server.PublishMode.CreateNew) - - def test_publish_missing_mode(self): - new_datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'test') - self.assertRaises(ValueError, self.server.datasources.publish, new_datasource, - asset('SampleDS.tds'), None) - - def test_publish_invalid_file_type(self): - new_datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'test') - self.assertRaises(ValueError, self.server.datasources.publish, new_datasource, - asset('SampleWB.twbx'), self.server.PublishMode.Append) - - def test_publish_hyper_file_object_raises_exception(self): - new_datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'test') - with open(asset('World Indicators.hyper')) as file_object: - self.assertRaises(ValueError, self.server.datasources.publish, new_datasource, - file_object, self.server.PublishMode.Append) - - def test_publish_tde_file_object_raises_exception(self): - - new_datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'test') - tds_asset = asset(os.path.join('Data', 'Tableau Samples', 'World Indicators.tde')) - with open(tds_asset) as file_object: - self.assertRaises(ValueError, self.server.datasources.publish, new_datasource, - file_object, self.server.PublishMode.Append) - - def test_publish_file_object_of_unknown_type_raises_exception(self): - new_datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'test') + def test_publish_missing_path(self) -> None: + new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "test") + self.assertRaises( + IOError, self.server.datasources.publish, new_datasource, "", self.server.PublishMode.CreateNew + ) + + def test_publish_missing_mode(self) -> None: + new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "test") + self.assertRaises(ValueError, self.server.datasources.publish, new_datasource, asset("SampleDS.tds"), None) + + def test_publish_invalid_file_type(self) -> None: + new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "test") + self.assertRaises( + ValueError, + self.server.datasources.publish, + new_datasource, + asset("SampleWB.twbx"), + self.server.PublishMode.Append, + ) + + def test_publish_hyper_file_object_raises_exception(self) -> None: + new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "test") + with open(asset("World Indicators.hyper"), "rb") as file_object: + self.assertRaises( + ValueError, self.server.datasources.publish, new_datasource, file_object, self.server.PublishMode.Append + ) + + def test_publish_tde_file_object_raises_exception(self) -> None: + + new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "test") + tds_asset = asset(os.path.join("Data", "Tableau Samples", "World Indicators.tde")) + with open(tds_asset, "rb") as file_object: + self.assertRaises( + ValueError, self.server.datasources.publish, new_datasource, file_object, self.server.PublishMode.Append + ) + + def test_publish_file_object_of_unknown_type_raises_exception(self) -> None: + new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "test") with BytesIO() as file_object: - file_object.write(bytes.fromhex('89504E470D0A1A0A')) + file_object.write(bytes.fromhex("89504E470D0A1A0A")) file_object.seek(0) - self.assertRaises(ValueError, self.server.datasources.publish, new_datasource, - file_object, self.server.PublishMode.Append) + self.assertRaises( + ValueError, self.server.datasources.publish, new_datasource, file_object, self.server.PublishMode.Append + ) - def test_publish_multi_connection(self): - new_datasource = TSC.DatasourceItem(name='Sample', project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + def test_publish_multi_connection(self) -> None: + new_datasource = TSC.DatasourceItem(name="Sample", project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") connection1 = TSC.ConnectionItem() - connection1.server_address = 'mysql.test.com' - connection1.connection_credentials = TSC.ConnectionCredentials('test', 'secret', True) + connection1.server_address = "mysql.test.com" + connection1.connection_credentials = TSC.ConnectionCredentials("test", "secret", True) connection2 = TSC.ConnectionItem() - connection2.server_address = 'pgsql.test.com' - connection2.connection_credentials = TSC.ConnectionCredentials('test', 'secret', True) + connection2.server_address = "pgsql.test.com" + connection2.connection_credentials = TSC.ConnectionCredentials("test", "secret", True) response = RequestFactory.Datasource._generate_xml(new_datasource, connections=[connection1, connection2]) # Can't use ConnectionItem parser due to xml namespace problems - connection_results = ET.fromstring(response).findall('.//connection') + connection_results = fromstring(response).findall(".//connection") - self.assertEqual(connection_results[0].get('serverAddress', None), 'mysql.test.com') - self.assertEqual(connection_results[0].find('connectionCredentials').get('name', None), 'test') - self.assertEqual(connection_results[1].get('serverAddress', None), 'pgsql.test.com') - self.assertEqual(connection_results[1].find('connectionCredentials').get('password', None), 'secret') + self.assertEqual(connection_results[0].get("serverAddress", None), "mysql.test.com") + self.assertEqual(connection_results[0].find("connectionCredentials").get("name", None), "test") # type: ignore[union-attr] + 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): - new_datasource = TSC.DatasourceItem(name='Sample', project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') - connection_creds = TSC.ConnectionCredentials('test', 'secret', True) + def test_publish_single_connection(self) -> None: + new_datasource = TSC.DatasourceItem(name="Sample", project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") + connection_creds = TSC.ConnectionCredentials("test", "secret", True) response = RequestFactory.Datasource._generate_xml(new_datasource, connection_credentials=connection_creds) # Can't use ConnectionItem parser due to xml namespace problems - credentials = ET.fromstring(response).findall('.//connectionCredentials') + 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') + 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_credentials_and_multi_connect_raises_exception(self): - new_datasource = TSC.DatasourceItem(name='Sample', project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + def test_credentials_and_multi_connect_raises_exception(self) -> None: + new_datasource = TSC.DatasourceItem(name="Sample", project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") - connection_creds = TSC.ConnectionCredentials('test', 'secret', True) + connection_creds = TSC.ConnectionCredentials("test", "secret", True) connection1 = TSC.ConnectionItem() - connection1.server_address = 'mysql.test.com' - connection1.connection_credentials = TSC.ConnectionCredentials('test', 'secret', True) + connection1.server_address = "mysql.test.com" + connection1.connection_credentials = TSC.ConnectionCredentials("test", "secret", True) with self.assertRaises(RuntimeError): - response = RequestFactory.Datasource._generate_xml(new_datasource, - connection_credentials=connection_creds, - connections=[connection1]) + response = RequestFactory.Datasource._generate_xml( + new_datasource, connection_credentials=connection_creds, connections=[connection1] + ) - def test_synchronous_publish_timeout_error(self): + def test_synchronous_publish_timeout_error(self) -> None: with requests_mock.mock() as m: - m.register_uri('POST', self.baseurl, status_code=504) + m.register_uri("POST", self.baseurl, status_code=504) - new_datasource = TSC.DatasourceItem(project_id='') + new_datasource = TSC.DatasourceItem(project_id="") publish_mode = self.server.PublishMode.CreateNew - self.assertRaisesRegex(InternalServerError, 'Please use asynchronous publishing to avoid timeouts.', - self.server.datasources.publish, new_datasource, - asset('SampleDS.tds'), publish_mode) + self.assertRaisesRegex( + InternalServerError, + "Please use asynchronous publishing to avoid timeouts.", + self.server.datasources.publish, + new_datasource, + asset("SampleDS.tds"), + publish_mode, + ) - def test_delete_extracts(self): + def test_delete_extracts(self) -> None: self.server.version = "3.10" self.baseurl = self.server.datasources.baseurl with requests_mock.mock() as m: - m.post(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/deleteExtract', status_code=200) - self.server.datasources.delete_extract('3cc6cd06-89ce-4fdc-b935-5294135d6d42') + m.post(self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/deleteExtract", status_code=200) + self.server.datasources.delete_extract("3cc6cd06-89ce-4fdc-b935-5294135d6d42") - def test_create_extracts(self): + def test_create_extracts(self) -> None: self.server.version = "3.10" self.baseurl = self.server.datasources.baseurl response_xml = read_xml_asset(PUBLISH_XML_ASYNC) with requests_mock.mock() as m: - m.post(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract', - status_code=200, text=response_xml) - self.server.datasources.create_extract('3cc6cd06-89ce-4fdc-b935-5294135d6d42') + m.post( + self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract", status_code=200, text=response_xml + ) + self.server.datasources.create_extract("3cc6cd06-89ce-4fdc-b935-5294135d6d42") - def test_create_extracts_encrypted(self): + def test_create_extracts_encrypted(self) -> None: self.server.version = "3.10" self.baseurl = self.server.datasources.baseurl response_xml = read_xml_asset(PUBLISH_XML_ASYNC) with requests_mock.mock() as m: - m.post(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract', - status_code=200, text=response_xml) - self.server.datasources.create_extract('3cc6cd06-89ce-4fdc-b935-5294135d6d42', True) + m.post( + self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract", status_code=200, text=response_xml + ) + self.server.datasources.create_extract("3cc6cd06-89ce-4fdc-b935-5294135d6d42", True) + + def test_revisions(self) -> None: + datasource = TSC.DatasourceItem("project", "test") + datasource._id = "06b944d2-959d-4604-9305-12323c95e70e" + + response_xml = read_xml_asset(REVISION_XML) + with requests_mock.mock() as m: + m.get("{0}/{1}/revisions".format(self.baseurl, datasource.id), text=response_xml) + self.server.datasources.populate_revisions(datasource) + revisions = datasource.revisions + + self.assertEqual(len(revisions), 3) + self.assertEqual("2016-07-26T20:34:56Z", format_datetime(revisions[0].created_at)) + self.assertEqual("2016-07-27T20:34:56Z", format_datetime(revisions[1].created_at)) + self.assertEqual("2016-07-28T20:34:56Z", format_datetime(revisions[2].created_at)) + + self.assertEqual(False, revisions[0].deleted) + self.assertEqual(False, revisions[0].current) + self.assertEqual(False, revisions[1].deleted) + self.assertEqual(False, revisions[1].current) + self.assertEqual(False, revisions[2].deleted) + self.assertEqual(True, revisions[2].current) + + self.assertEqual("Cassie", revisions[0].user_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", revisions[0].user_id) + self.assertIsNone(revisions[1].user_name) + self.assertIsNone(revisions[1].user_id) + self.assertEqual("Cassie", revisions[2].user_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", revisions[2].user_id) + + def test_delete_revision(self) -> None: + datasource = TSC.DatasourceItem("project", "test") + datasource._id = "06b944d2-959d-4604-9305-12323c95e70e" + + with requests_mock.mock() as m: + m.delete("{0}/{1}/revisions/3".format(self.baseurl, datasource.id)) + self.server.datasources.delete_revision(datasource.id, "3") + + def test_download_revision(self) -> None: + with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td: + m.get( + self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/revisions/3/content", + headers={"Content-Disposition": 'name="tableau_datasource"; filename="Sample datasource.tds"'}, + ) + file_path = self.server.datasources.download_revision("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", "3", td) + self.assertTrue(os.path.exists(file_path)) diff --git a/test/test_datasource_model.py b/test/test_datasource_model.py index 600587801..81a26b068 100644 --- a/test/test_datasource_model.py +++ b/test/test_datasource_model.py @@ -1,5 +1,5 @@ -import datetime import unittest + import tableauserverclient as TSC diff --git a/test/test_exponential_backoff.py b/test/test_exponential_backoff.py index 57229d4ce..a07eb5d3a 100644 --- a/test/test_exponential_backoff.py +++ b/test/test_exponential_backoff.py @@ -1,6 +1,7 @@ import unittest -from ._utils import mocked_time + from tableauserverclient.exponential_backoff import ExponentialBackoffTimer +from ._utils import mocked_time class ExponentialBackoffTests(unittest.TestCase): @@ -21,7 +22,6 @@ def test_exponential(self): exponentialBackoff.sleep() self.assertAlmostEqual(mock_time(), 5.4728) - def test_exponential_saturation(self): with mocked_time() as mock_time: exponentialBackoff = ExponentialBackoffTimer() @@ -36,7 +36,6 @@ def test_exponential_saturation(self): slept = mock_time() - s self.assertAlmostEqual(slept, 30) - def test_timeout(self): with mocked_time() as mock_time: exponentialBackoff = ExponentialBackoffTimer(timeout=4.5) @@ -52,11 +51,10 @@ def test_timeout(self): with self.assertRaises(TimeoutError): exponentialBackoff.sleep() - def test_timeout_zero(self): with mocked_time() as mock_time: # The construction of the timer doesn't throw, yet - exponentialBackoff = ExponentialBackoffTimer(timeout = 0) + exponentialBackoff = ExponentialBackoffTimer(timeout=0) # But the first `sleep` immediately throws with self.assertRaises(TimeoutError): exponentialBackoff.sleep() diff --git a/test/test_favorites.py b/test/test_favorites.py index f76517b64..9dcc3bb38 100644 --- a/test/test_favorites.py +++ b/test/test_favorites.py @@ -1,129 +1,117 @@ import unittest -import os + import requests_mock -import xml.etree.ElementTree as ET + import tableauserverclient as TSC -from tableauserverclient.datetime_helpers import format_datetime -from tableauserverclient.server.endpoint.exceptions import InternalServerError -from tableauserverclient.server.request_factory import RequestFactory -from ._utils import read_xml_asset, read_xml_assets, asset +from ._utils import read_xml_asset -GET_FAVORITES_XML = 'favorites_get.xml' -ADD_FAVORITE_WORKBOOK_XML = 'favorites_add_workbook.xml' -ADD_FAVORITE_VIEW_XML = 'favorites_add_view.xml' -ADD_FAVORITE_DATASOURCE_XML = 'favorites_add_datasource.xml' -ADD_FAVORITE_PROJECT_XML = 'favorites_add_project.xml' +GET_FAVORITES_XML = "favorites_get.xml" +ADD_FAVORITE_WORKBOOK_XML = "favorites_add_workbook.xml" +ADD_FAVORITE_VIEW_XML = "favorites_add_view.xml" +ADD_FAVORITE_DATASOURCE_XML = "favorites_add_datasource.xml" +ADD_FAVORITE_PROJECT_XML = "favorites_add_project.xml" class FavoritesTests(unittest.TestCase): def setUp(self): - self.server = TSC.Server('http://test') - self.server.version = '2.5' + self.server = TSC.Server("http://test", False) + self.server.version = "2.5" # Fake signin - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.baseurl = self.server.favorites.baseurl - self.user = TSC.UserItem('alice', TSC.UserItem.Roles.Viewer) - self.user._id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' + self.user = TSC.UserItem("alice", TSC.UserItem.Roles.Viewer) + self.user._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" - def test_get(self): + def test_get(self) -> None: response_xml = read_xml_asset(GET_FAVORITES_XML) with requests_mock.mock() as m: - m.get('{0}/{1}'.format(self.baseurl, self.user.id), - text=response_xml) + m.get("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) self.server.favorites.get(self.user) self.assertIsNotNone(self.user._favorites) - self.assertEqual(len(self.user.favorites['workbooks']), 1) - self.assertEqual(len(self.user.favorites['views']), 1) - self.assertEqual(len(self.user.favorites['projects']), 1) - self.assertEqual(len(self.user.favorites['datasources']), 1) - - workbook = self.user.favorites['workbooks'][0] - view = self.user.favorites['views'][0] - datasource = self.user.favorites['datasources'][0] - project = self.user.favorites['projects'][0] - - self.assertEqual(workbook.id, '6d13b0ca-043d-4d42-8c9d-3f3313ea3a00') - self.assertEqual(view.id, 'd79634e1-6063-4ec9-95ff-50acbf609ff5') - self.assertEqual(datasource.id, 'e76a1461-3b1d-4588-bf1b-17551a879ad9') - self.assertEqual(project.id, '1d0304cd-3796-429f-b815-7258370b9b74') - - def test_add_favorite_workbook(self): + self.assertEqual(len(self.user.favorites["workbooks"]), 1) + self.assertEqual(len(self.user.favorites["views"]), 1) + self.assertEqual(len(self.user.favorites["projects"]), 1) + self.assertEqual(len(self.user.favorites["datasources"]), 1) + + workbook = self.user.favorites["workbooks"][0] + view = self.user.favorites["views"][0] + datasource = self.user.favorites["datasources"][0] + project = self.user.favorites["projects"][0] + + self.assertEqual(workbook.id, "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00") + self.assertEqual(view.id, "d79634e1-6063-4ec9-95ff-50acbf609ff5") + self.assertEqual(datasource.id, "e76a1461-3b1d-4588-bf1b-17551a879ad9") + self.assertEqual(project.id, "1d0304cd-3796-429f-b815-7258370b9b74") + + def test_add_favorite_workbook(self) -> None: response_xml = read_xml_asset(ADD_FAVORITE_WORKBOOK_XML) - workbook = TSC.WorkbookItem('') - workbook._id = '6d13b0ca-043d-4d42-8c9d-3f3313ea3a00' - workbook.name = 'Superstore' + workbook = TSC.WorkbookItem("") + workbook._id = "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00" + workbook.name = "Superstore" with requests_mock.mock() as m: - m.put('{0}/{1}'.format(self.baseurl, self.user.id), - text=response_xml) + m.put("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) self.server.favorites.add_favorite_workbook(self.user, workbook) - def test_add_favorite_view(self): + def test_add_favorite_view(self) -> None: response_xml = read_xml_asset(ADD_FAVORITE_VIEW_XML) view = TSC.ViewItem() - view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' - view._name = 'ENDANGERED SAFARI' + view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + view._name = "ENDANGERED SAFARI" with requests_mock.mock() as m: - m.put('{0}/{1}'.format(self.baseurl, self.user.id), - text=response_xml) + m.put("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) self.server.favorites.add_favorite_view(self.user, view) - def test_add_favorite_datasource(self): + def test_add_favorite_datasource(self) -> None: response_xml = read_xml_asset(ADD_FAVORITE_DATASOURCE_XML) - datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') - datasource._id = 'e76a1461-3b1d-4588-bf1b-17551a879ad9' - datasource.name = 'SampleDS' + datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") + datasource._id = "e76a1461-3b1d-4588-bf1b-17551a879ad9" + datasource.name = "SampleDS" with requests_mock.mock() as m: - m.put('{0}/{1}'.format(self.baseurl, self.user.id), - text=response_xml) + m.put("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) self.server.favorites.add_favorite_datasource(self.user, datasource) - def test_add_favorite_project(self): - self.server.version = '3.1' + def test_add_favorite_project(self) -> None: + self.server.version = "3.1" baseurl = self.server.favorites.baseurl response_xml = read_xml_asset(ADD_FAVORITE_PROJECT_XML) - project = TSC.ProjectItem('Tableau') - project._id = '1d0304cd-3796-429f-b815-7258370b9b74' + project = TSC.ProjectItem("Tableau") + project._id = "1d0304cd-3796-429f-b815-7258370b9b74" with requests_mock.mock() as m: - m.put('{0}/{1}'.format(baseurl, self.user.id), - text=response_xml) + m.put("{0}/{1}".format(baseurl, self.user.id), text=response_xml) self.server.favorites.add_favorite_project(self.user, project) - def test_delete_favorite_workbook(self): - workbook = TSC.WorkbookItem('') - workbook._id = '6d13b0ca-043d-4d42-8c9d-3f3313ea3a00' - workbook.name = 'Superstore' + def test_delete_favorite_workbook(self) -> None: + workbook = TSC.WorkbookItem("") + workbook._id = "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00" + workbook.name = "Superstore" with requests_mock.mock() as m: - m.delete('{0}/{1}/workbooks/{2}'.format(self.baseurl, self.user.id, - workbook.id)) + m.delete("{0}/{1}/workbooks/{2}".format(self.baseurl, self.user.id, workbook.id)) self.server.favorites.delete_favorite_workbook(self.user, workbook) - def test_delete_favorite_view(self): + def test_delete_favorite_view(self) -> None: view = TSC.ViewItem() - view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' - view._name = 'ENDANGERED SAFARI' + view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + view._name = "ENDANGERED SAFARI" with requests_mock.mock() as m: - m.delete('{0}/{1}/views/{2}'.format(self.baseurl, self.user.id, - view.id)) + m.delete("{0}/{1}/views/{2}".format(self.baseurl, self.user.id, view.id)) self.server.favorites.delete_favorite_view(self.user, view) - def test_delete_favorite_datasource(self): - datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') - datasource._id = 'e76a1461-3b1d-4588-bf1b-17551a879ad9' - datasource.name = 'SampleDS' + def test_delete_favorite_datasource(self) -> None: + datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") + datasource._id = "e76a1461-3b1d-4588-bf1b-17551a879ad9" + datasource.name = "SampleDS" with requests_mock.mock() as m: - m.delete('{0}/{1}/datasources/{2}'.format(self.baseurl, self.user.id, - datasource.id)) + m.delete("{0}/{1}/datasources/{2}".format(self.baseurl, self.user.id, datasource.id)) self.server.favorites.delete_favorite_datasource(self.user, datasource) - def test_delete_favorite_project(self): - self.server.version = '3.1' + def test_delete_favorite_project(self) -> None: + self.server.version = "3.1" baseurl = self.server.favorites.baseurl - project = TSC.ProjectItem('Tableau') - project._id = '1d0304cd-3796-429f-b815-7258370b9b74' + project = TSC.ProjectItem("Tableau") + project._id = "1d0304cd-3796-429f-b815-7258370b9b74" with requests_mock.mock() as m: - m.delete('{0}/{1}/projects/{2}'.format(baseurl, self.user.id, - project.id)) + m.delete("{0}/{1}/projects/{2}".format(baseurl, self.user.id, project.id)) self.server.favorites.delete_favorite_project(self.user, project) diff --git a/test/test_filesys_helpers.py b/test/test_filesys_helpers.py index 82fce8476..645c5d372 100644 --- a/test/test_filesys_helpers.py +++ b/test/test_filesys_helpers.py @@ -1,6 +1,6 @@ +import os import unittest from io import BytesIO -import os from xml.etree import ElementTree as ET from zipfile import ZipFile @@ -9,7 +9,6 @@ class FilesysTests(unittest.TestCase): - def test_get_file_size_returns_correct_size(self): target_size = 1000 # bytes @@ -30,9 +29,9 @@ def test_get_file_size_returns_zero_for_empty_file(self): def test_get_file_size_coincides_with_built_in_method(self): - asset_path = asset('SampleWB.twbx') + asset_path = asset("SampleWB.twbx") target_size = os.path.getsize(asset_path) - with open(asset_path, 'rb') as f: + with open(asset_path, "rb") as f: file_size = get_file_object_size(f) self.assertEqual(file_size, target_size) @@ -40,61 +39,60 @@ def test_get_file_size_coincides_with_built_in_method(self): def test_get_file_type_identifies_a_zip_file(self): with BytesIO() as file_object: - with ZipFile(file_object, 'w') as zf: + with ZipFile(file_object, "w") as zf: with BytesIO() as stream: - stream.write('This is a zip file'.encode()) - zf.writestr('dummy_file', stream.getbuffer()) + stream.write("This is a zip file".encode()) + zf.writestr("dummy_file", stream.getbuffer()) file_object.seek(0) file_type = get_file_type(file_object) - self.assertEqual(file_type, 'zip') + self.assertEqual(file_type, "zip") def test_get_file_type_identifies_tdsx_as_zip_file(self): - with open(asset('World Indicators.tdsx'), 'rb') as file_object: + with open(asset("World Indicators.tdsx"), "rb") as file_object: file_type = get_file_type(file_object) - self.assertEqual(file_type, 'zip') + self.assertEqual(file_type, "zip") def test_get_file_type_identifies_twbx_as_zip_file(self): - with open(asset('SampleWB.twbx'), 'rb') as file_object: + with open(asset("SampleWB.twbx"), "rb") as file_object: file_type = get_file_type(file_object) - self.assertEqual(file_type, 'zip') + self.assertEqual(file_type, "zip") def test_get_file_type_identifies_xml_file(self): - root = ET.Element('root') - child = ET.SubElement(root, 'child') + root = ET.Element("root") + child = ET.SubElement(root, "child") child.text = "This is a child element" etree = ET.ElementTree(root) with BytesIO() as file_object: - etree.write(file_object, encoding='utf-8', - xml_declaration=True) + etree.write(file_object, encoding="utf-8", xml_declaration=True) file_object.seek(0) file_type = get_file_type(file_object) - self.assertEqual(file_type, 'xml') + self.assertEqual(file_type, "xml") def test_get_file_type_identifies_tds_as_xml_file(self): - with open(asset('World Indicators.tds'), 'rb') as file_object: + with open(asset("World Indicators.tds"), "rb") as file_object: file_type = get_file_type(file_object) - self.assertEqual(file_type, 'xml') + self.assertEqual(file_type, "xml") def test_get_file_type_identifies_twb_as_xml_file(self): - with open(asset('RESTAPISample.twb'), 'rb') as file_object: + with open(asset("RESTAPISample.twb"), "rb") as file_object: file_type = get_file_type(file_object) - self.assertEqual(file_type, 'xml') + self.assertEqual(file_type, "xml") def test_get_file_type_identifies_hyper_file(self): - with open(asset('World Indicators.hyper'), 'rb') as file_object: + with open(asset("World Indicators.hyper"), "rb") as file_object: file_type = get_file_type(file_object) - self.assertEqual(file_type, 'hyper') + self.assertEqual(file_type, "hyper") def test_get_file_type_identifies_tde_file(self): - asset_path = os.path.join(TEST_ASSET_DIR, 'Data', 'Tableau Samples', 'World Indicators.tde') - with open(asset_path, 'rb') as file_object: + asset_path = os.path.join(TEST_ASSET_DIR, "Data", "Tableau Samples", "World Indicators.tde") + with open(asset_path, "rb") as file_object: file_type = get_file_type(file_object) - self.assertEqual(file_type, 'tde') + self.assertEqual(file_type, "tde") def test_get_file_type_handles_unknown_file_type(self): diff --git a/test/test_fileuploads.py b/test/test_fileuploads.py index 51662e4a2..4d3b0c864 100644 --- a/test/test_fileuploads.py +++ b/test/test_fileuploads.py @@ -1,63 +1,64 @@ import os -import requests_mock import unittest -from ._utils import asset +import requests_mock + from tableauserverclient.server import Server +from ._utils import asset -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') -FILEUPLOAD_INITIALIZE = os.path.join(TEST_ASSET_DIR, 'fileupload_initialize.xml') -FILEUPLOAD_APPEND = os.path.join(TEST_ASSET_DIR, 'fileupload_append.xml') +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") +FILEUPLOAD_INITIALIZE = os.path.join(TEST_ASSET_DIR, "fileupload_initialize.xml") +FILEUPLOAD_APPEND = os.path.join(TEST_ASSET_DIR, "fileupload_append.xml") class FileuploadsTests(unittest.TestCase): def setUp(self): - self.server = Server('http://test') + self.server = Server("http://test", False) # Fake sign in - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - self.baseurl = '{}/sites/{}/fileUploads'.format(self.server.baseurl, self.server.site_id) + self.baseurl = "{}/sites/{}/fileUploads".format(self.server.baseurl, self.server.site_id) def test_read_chunks_file_path(self): - file_path = asset('SampleWB.twbx') + file_path = asset("SampleWB.twbx") chunks = self.server.fileuploads._read_chunks(file_path) for chunk in chunks: self.assertIsNotNone(chunk) def test_read_chunks_file_object(self): - with open(asset('SampleWB.twbx'), 'rb') as f: + with open(asset("SampleWB.twbx"), "rb") as f: chunks = self.server.fileuploads._read_chunks(f) for chunk in chunks: self.assertIsNotNone(chunk) def test_upload_chunks_file_path(self): - file_path = asset('SampleWB.twbx') - upload_id = '7720:170fe6b1c1c7422dadff20f944d58a52-1:0' + file_path = asset("SampleWB.twbx") + upload_id = "7720:170fe6b1c1c7422dadff20f944d58a52-1:0" - with open(FILEUPLOAD_INITIALIZE, 'rb') as f: - initialize_response_xml = f.read().decode('utf-8') - with open(FILEUPLOAD_APPEND, 'rb') as f: - append_response_xml = f.read().decode('utf-8') + with open(FILEUPLOAD_INITIALIZE, "rb") as f: + initialize_response_xml = f.read().decode("utf-8") + with open(FILEUPLOAD_APPEND, "rb") as f: + append_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=initialize_response_xml) - m.put(self.baseurl + '/' + upload_id, text=append_response_xml) + m.put(self.baseurl + "/" + upload_id, text=append_response_xml) actual = self.server.fileuploads.upload(file_path) self.assertEqual(upload_id, actual) def test_upload_chunks_file_object(self): - upload_id = '7720:170fe6b1c1c7422dadff20f944d58a52-1:0' + upload_id = "7720:170fe6b1c1c7422dadff20f944d58a52-1:0" - with open(asset('SampleWB.twbx'), 'rb') as file_content: - with open(FILEUPLOAD_INITIALIZE, 'rb') as f: - initialize_response_xml = f.read().decode('utf-8') - with open(FILEUPLOAD_APPEND, 'rb') as f: - append_response_xml = f.read().decode('utf-8') + with open(asset("SampleWB.twbx"), "rb") as file_content: + with open(FILEUPLOAD_INITIALIZE, "rb") as f: + initialize_response_xml = f.read().decode("utf-8") + with open(FILEUPLOAD_APPEND, "rb") as f: + append_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=initialize_response_xml) - m.put(self.baseurl + '/' + upload_id, text=append_response_xml) + m.put(self.baseurl + "/" + upload_id, text=append_response_xml) actual = self.server.fileuploads.upload(file_content) self.assertEqual(upload_id, actual) diff --git a/test/test_flow.py b/test/test_flow.py index 545623d03..269bc2f7e 100644 --- a/test/test_flow.py +++ b/test/test_flow.py @@ -1,135 +1,135 @@ import unittest -import os + import requests_mock -import xml.etree.ElementTree as ET + import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime -from tableauserverclient.server.endpoint.exceptions import InternalServerError -from tableauserverclient.server.request_factory import RequestFactory -from ._utils import read_xml_asset, read_xml_assets, asset +from ._utils import read_xml_asset, asset -GET_XML = 'flow_get.xml' -POPULATE_CONNECTIONS_XML = 'flow_populate_connections.xml' -POPULATE_PERMISSIONS_XML = 'flow_populate_permissions.xml' -UPDATE_XML = 'flow_update.xml' -REFRESH_XML = 'flow_refresh.xml' +GET_XML = "flow_get.xml" +POPULATE_CONNECTIONS_XML = "flow_populate_connections.xml" +POPULATE_PERMISSIONS_XML = "flow_populate_permissions.xml" +UPDATE_XML = "flow_update.xml" +REFRESH_XML = "flow_refresh.xml" class FlowTests(unittest.TestCase): - def setUp(self): - self.server = TSC.Server('http://test') + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) # Fake signin - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.server.version = "3.5" self.baseurl = self.server.flows.baseurl - def test_get(self): + def test_get(self) -> None: response_xml = read_xml_asset(GET_XML) with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) all_flows, pagination_item = self.server.flows.get() self.assertEqual(5, pagination_item.total_available) - self.assertEqual('587daa37-b84d-4400-a9a2-aa90e0be7837', all_flows[0].id) - self.assertEqual('http://tableauserver/#/flows/1', all_flows[0].webpage_url) - self.assertEqual('2019-06-16T21:43:28Z', format_datetime(all_flows[0].created_at)) - self.assertEqual('2019-06-16T21:43:28Z', format_datetime(all_flows[0].updated_at)) - self.assertEqual('Default', all_flows[0].project_name) - self.assertEqual('FlowOne', all_flows[0].name) - self.assertEqual('aa23f4ac-906f-11e9-86fb-3f0f71412e77', all_flows[0].project_id) - self.assertEqual('7ebb3f20-0fd2-4f27-a2f6-c539470999e2', all_flows[0].owner_id) - self.assertEqual({'i_love_tags'}, all_flows[0].tags) - self.assertEqual('Descriptive', all_flows[0].description) - - self.assertEqual('5c36be69-eb30-461b-b66e-3e2a8e27cc35', all_flows[1].id) - self.assertEqual('http://tableauserver/#/flows/4', all_flows[1].webpage_url) - self.assertEqual('2019-06-18T03:08:19Z', format_datetime(all_flows[1].created_at)) - self.assertEqual('2019-06-18T03:08:19Z', format_datetime(all_flows[1].updated_at)) - self.assertEqual('Default', all_flows[1].project_name) - self.assertEqual('FlowTwo', all_flows[1].name) - self.assertEqual('aa23f4ac-906f-11e9-86fb-3f0f71412e77', all_flows[1].project_id) - self.assertEqual('9127d03f-d996-405f-b392-631b25183a0f', all_flows[1].owner_id) - - def test_update(self): + self.assertEqual("587daa37-b84d-4400-a9a2-aa90e0be7837", all_flows[0].id) + self.assertEqual("http://tableauserver/#/flows/1", all_flows[0].webpage_url) + self.assertEqual("2019-06-16T21:43:28Z", format_datetime(all_flows[0].created_at)) + self.assertEqual("2019-06-16T21:43:28Z", format_datetime(all_flows[0].updated_at)) + self.assertEqual("Default", all_flows[0].project_name) + self.assertEqual("FlowOne", all_flows[0].name) + self.assertEqual("aa23f4ac-906f-11e9-86fb-3f0f71412e77", all_flows[0].project_id) + self.assertEqual("7ebb3f20-0fd2-4f27-a2f6-c539470999e2", all_flows[0].owner_id) + self.assertEqual({"i_love_tags"}, all_flows[0].tags) + self.assertEqual("Descriptive", all_flows[0].description) + + self.assertEqual("5c36be69-eb30-461b-b66e-3e2a8e27cc35", all_flows[1].id) + self.assertEqual("http://tableauserver/#/flows/4", all_flows[1].webpage_url) + self.assertEqual("2019-06-18T03:08:19Z", format_datetime(all_flows[1].created_at)) + self.assertEqual("2019-06-18T03:08:19Z", format_datetime(all_flows[1].updated_at)) + self.assertEqual("Default", all_flows[1].project_name) + self.assertEqual("FlowTwo", all_flows[1].name) + self.assertEqual("aa23f4ac-906f-11e9-86fb-3f0f71412e77", all_flows[1].project_id) + self.assertEqual("9127d03f-d996-405f-b392-631b25183a0f", all_flows[1].owner_id) + + def test_update(self) -> None: response_xml = read_xml_asset(UPDATE_XML) with requests_mock.mock() as m: - m.put(self.baseurl + '/587daa37-b84d-4400-a9a2-aa90e0be7837', text=response_xml) - single_datasource = TSC.FlowItem('test', 'aa23f4ac-906f-11e9-86fb-3f0f71412e77') - single_datasource.owner_id = '7ebb3f20-0fd2-4f27-a2f6-c539470999e2' - single_datasource._id = '587daa37-b84d-4400-a9a2-aa90e0be7837' + m.put(self.baseurl + "/587daa37-b84d-4400-a9a2-aa90e0be7837", text=response_xml) + single_datasource = TSC.FlowItem("test", "aa23f4ac-906f-11e9-86fb-3f0f71412e77") + single_datasource.owner_id = "7ebb3f20-0fd2-4f27-a2f6-c539470999e2" + single_datasource._id = "587daa37-b84d-4400-a9a2-aa90e0be7837" single_datasource.description = "So fun to see" single_datasource = self.server.flows.update(single_datasource) - self.assertEqual('587daa37-b84d-4400-a9a2-aa90e0be7837', single_datasource.id) - self.assertEqual('aa23f4ac-906f-11e9-86fb-3f0f71412e77', single_datasource.project_id) - self.assertEqual('7ebb3f20-0fd2-4f27-a2f6-c539470999e2', single_datasource.owner_id) + self.assertEqual("587daa37-b84d-4400-a9a2-aa90e0be7837", single_datasource.id) + self.assertEqual("aa23f4ac-906f-11e9-86fb-3f0f71412e77", single_datasource.project_id) + self.assertEqual("7ebb3f20-0fd2-4f27-a2f6-c539470999e2", single_datasource.owner_id) self.assertEqual("So fun to see", single_datasource.description) - def test_populate_connections(self): + def test_populate_connections(self) -> None: response_xml = read_xml_asset(POPULATE_CONNECTIONS_XML) with requests_mock.mock() as m: - m.get(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections', text=response_xml) - single_datasource = TSC.FlowItem('test', 'aa23f4ac-906f-11e9-86fb-3f0f71412e77') - single_datasource.owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' - single_datasource._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' + m.get(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections", text=response_xml) + single_datasource = TSC.FlowItem("test", "aa23f4ac-906f-11e9-86fb-3f0f71412e77") + single_datasource.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" self.server.flows.populate_connections(single_datasource) - self.assertEqual('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', single_datasource.id) + self.assertEqual("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", single_datasource.id) connections = single_datasource.connections self.assertTrue(connections) conn1, conn2, conn3 = connections - self.assertEqual('405c1e4b-60c9-499f-9c47-a4ef1af69359', conn1.id) - self.assertEqual('excel-direct', conn1.connection_type) - self.assertEqual('', conn1.server_address) - self.assertEqual('', conn1.username) + self.assertEqual("405c1e4b-60c9-499f-9c47-a4ef1af69359", conn1.id) + self.assertEqual("excel-direct", conn1.connection_type) + self.assertEqual("", conn1.server_address) + self.assertEqual("", conn1.username) self.assertEqual(False, conn1.embed_password) - self.assertEqual('b47f41b1-2c47-41a3-8b17-a38ebe8b340c', conn2.id) - self.assertEqual('sqlserver', conn2.connection_type) - self.assertEqual('test.database.com', conn2.server_address) - self.assertEqual('bob', conn2.username) + self.assertEqual("b47f41b1-2c47-41a3-8b17-a38ebe8b340c", conn2.id) + self.assertEqual("sqlserver", conn2.connection_type) + self.assertEqual("test.database.com", conn2.server_address) + self.assertEqual("bob", conn2.username) self.assertEqual(False, conn2.embed_password) - self.assertEqual('4f4a3b78-0554-43a7-b327-9605e9df9dd2', conn3.id) - self.assertEqual('tableau-server-site', conn3.connection_type) - self.assertEqual('http://tableauserver', conn3.server_address) - self.assertEqual('sally', conn3.username) + self.assertEqual("4f4a3b78-0554-43a7-b327-9605e9df9dd2", conn3.id) + self.assertEqual("tableau-server-site", conn3.connection_type) + self.assertEqual("http://tableauserver", conn3.server_address) + self.assertEqual("sally", conn3.username) self.assertEqual(True, conn3.embed_password) - def test_populate_permissions(self): - with open(asset(POPULATE_PERMISSIONS_XML), 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_populate_permissions(self) -> None: + with open(asset(POPULATE_PERMISSIONS_XML), "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions', text=response_xml) - single_datasource = TSC.FlowItem('test') - single_datasource._id = '0448d2ed-590d-4fa0-b272-a2a8a24555b5' + m.get(self.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions", text=response_xml) + single_datasource = TSC.FlowItem("test") + single_datasource._id = "0448d2ed-590d-4fa0-b272-a2a8a24555b5" self.server.flows.populate_permissions(single_datasource) permissions = single_datasource.permissions - self.assertEqual(permissions[0].grantee.tag_name, 'group') - self.assertEqual(permissions[0].grantee.id, 'aa42f384-906f-11e9-86fc-bb24278874b9') - self.assertDictEqual(permissions[0].capabilities, { - TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, - }) + self.assertEqual(permissions[0].grantee.tag_name, "group") + self.assertEqual(permissions[0].grantee.id, "aa42f384-906f-11e9-86fc-bb24278874b9") + self.assertDictEqual( + permissions[0].capabilities, + { + TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + }, + ) def test_refresh(self): - with open(asset(REFRESH_XML), 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(asset(REFRESH_XML), "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(self.baseurl + '/92967d2d-c7e2-46d0-8847-4802df58f484/run', text=response_xml) - flow_item = TSC.FlowItem('test') - flow_item._id = '92967d2d-c7e2-46d0-8847-4802df58f484' + m.post(self.baseurl + "/92967d2d-c7e2-46d0-8847-4802df58f484/run", text=response_xml) + flow_item = TSC.FlowItem("test") + flow_item._id = "92967d2d-c7e2-46d0-8847-4802df58f484" refresh_job = self.server.flows.refresh(flow_item) - self.assertEqual(refresh_job.id, 'd1b2ccd0-6dfa-444a-aee4-723dbd6b7c9d') - self.assertEqual(refresh_job.mode, 'Asynchronous') - self.assertEqual(refresh_job.type, 'RunFlow') - self.assertEqual(format_datetime(refresh_job.created_at), '2018-05-22T13:00:29Z') + self.assertEqual(refresh_job.id, "d1b2ccd0-6dfa-444a-aee4-723dbd6b7c9d") + self.assertEqual(refresh_job.mode, "Asynchronous") + self.assertEqual(refresh_job.type, "RunFlow") + self.assertEqual(format_datetime(refresh_job.created_at), "2018-05-22T13:00:29Z") self.assertIsInstance(refresh_job.flow_run, TSC.FlowRunItem) - self.assertEqual(refresh_job.flow_run.id, 'e0c3067f-2333-4eee-8028-e0a56ca496f6') - self.assertEqual(refresh_job.flow_run.flow_id, '92967d2d-c7e2-46d0-8847-4802df58f484') - self.assertEqual(format_datetime(refresh_job.flow_run.started_at), '2018-05-22T13:00:29Z') - + self.assertEqual(refresh_job.flow_run.id, "e0c3067f-2333-4eee-8028-e0a56ca496f6") + self.assertEqual(refresh_job.flow_run.flow_id, "92967d2d-c7e2-46d0-8847-4802df58f484") + self.assertEqual(format_datetime(refresh_job.flow_run.started_at), "2018-05-22T13:00:29Z") diff --git a/test/test_flowruns.py b/test/test_flowruns.py index d2e72f31a..864c0d3cd 100644 --- a/test/test_flowruns.py +++ b/test/test_flowruns.py @@ -1,104 +1,100 @@ import unittest -import os + import requests_mock -import xml.etree.ElementTree as ET + import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime from tableauserverclient.server.endpoint.exceptions import FlowRunFailedException -from tableauserverclient.server.request_factory import RequestFactory from ._utils import read_xml_asset, mocked_time -GET_XML = 'flow_runs_get.xml' -GET_BY_ID_XML = 'flow_runs_get_by_id.xml' -GET_BY_ID_FAILED_XML = 'flow_runs_get_by_id_failed.xml' -GET_BY_ID_INPROGRESS_XML = 'flow_runs_get_by_id_inprogress.xml' +GET_XML = "flow_runs_get.xml" +GET_BY_ID_XML = "flow_runs_get_by_id.xml" +GET_BY_ID_FAILED_XML = "flow_runs_get_by_id_failed.xml" +GET_BY_ID_INPROGRESS_XML = "flow_runs_get_by_id_inprogress.xml" class FlowRunTests(unittest.TestCase): - def setUp(self): - self.server = TSC.Server('http://test') + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) # Fake signin - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.server.version = "3.10" self.baseurl = self.server.flow_runs.baseurl - def test_get(self): + def test_get(self) -> None: response_xml = read_xml_asset(GET_XML) with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) all_flow_runs, pagination_item = self.server.flow_runs.get() self.assertEqual(2, pagination_item.total_available) - self.assertEqual('cc2e652d-4a9b-4476-8c93-b238c45db968', all_flow_runs[0].id) - self.assertEqual('2021-02-11T01:42:55Z', format_datetime(all_flow_runs[0].started_at)) - self.assertEqual('2021-02-11T01:57:38Z', format_datetime(all_flow_runs[0].completed_at)) - self.assertEqual('Success', all_flow_runs[0].status) - self.assertEqual('100', all_flow_runs[0].progress) - self.assertEqual('aa23f4ac-906f-11e9-86fb-3f0f71412e77', all_flow_runs[0].background_job_id) - - self.assertEqual('a3104526-c0c6-4ea5-8362-e03fc7cbd7ee', all_flow_runs[1].id) - self.assertEqual('2021-02-13T04:05:30Z', format_datetime(all_flow_runs[1].started_at)) - self.assertEqual('2021-02-13T04:05:35Z', format_datetime(all_flow_runs[1].completed_at)) - self.assertEqual('Failed', all_flow_runs[1].status) - self.assertEqual('100', all_flow_runs[1].progress) - self.assertEqual('1ad21a9d-2530-4fbf-9064-efd3c736e023', all_flow_runs[1].background_job_id) - - def test_get_by_id(self): + self.assertEqual("cc2e652d-4a9b-4476-8c93-b238c45db968", all_flow_runs[0].id) + self.assertEqual("2021-02-11T01:42:55Z", format_datetime(all_flow_runs[0].started_at)) + self.assertEqual("2021-02-11T01:57:38Z", format_datetime(all_flow_runs[0].completed_at)) + self.assertEqual("Success", all_flow_runs[0].status) + self.assertEqual("100", all_flow_runs[0].progress) + self.assertEqual("aa23f4ac-906f-11e9-86fb-3f0f71412e77", all_flow_runs[0].background_job_id) + + self.assertEqual("a3104526-c0c6-4ea5-8362-e03fc7cbd7ee", all_flow_runs[1].id) + self.assertEqual("2021-02-13T04:05:30Z", format_datetime(all_flow_runs[1].started_at)) + self.assertEqual("2021-02-13T04:05:35Z", format_datetime(all_flow_runs[1].completed_at)) + self.assertEqual("Failed", all_flow_runs[1].status) + self.assertEqual("100", all_flow_runs[1].progress) + self.assertEqual("1ad21a9d-2530-4fbf-9064-efd3c736e023", all_flow_runs[1].background_job_id) + + def test_get_by_id(self) -> None: response_xml = read_xml_asset(GET_BY_ID_XML) with requests_mock.mock() as m: m.get(self.baseurl + "/cc2e652d-4a9b-4476-8c93-b238c45db968", text=response_xml) flow_run = self.server.flow_runs.get_by_id("cc2e652d-4a9b-4476-8c93-b238c45db968") - - self.assertEqual('cc2e652d-4a9b-4476-8c93-b238c45db968', flow_run.id) - self.assertEqual('2021-02-11T01:42:55Z', format_datetime(flow_run.started_at)) - self.assertEqual('2021-02-11T01:57:38Z', format_datetime(flow_run.completed_at)) - self.assertEqual('Success', flow_run.status) - self.assertEqual('100', flow_run.progress) - self.assertEqual('1ad21a9d-2530-4fbf-9064-efd3c736e023', flow_run.background_job_id) - - def test_cancel_id(self): + + self.assertEqual("cc2e652d-4a9b-4476-8c93-b238c45db968", flow_run.id) + self.assertEqual("2021-02-11T01:42:55Z", format_datetime(flow_run.started_at)) + self.assertEqual("2021-02-11T01:57:38Z", format_datetime(flow_run.completed_at)) + self.assertEqual("Success", flow_run.status) + self.assertEqual("100", flow_run.progress) + self.assertEqual("1ad21a9d-2530-4fbf-9064-efd3c736e023", flow_run.background_job_id) + + def test_cancel_id(self) -> None: with requests_mock.mock() as m: - m.put(self.baseurl + '/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', status_code=204) - self.server.flow_runs.cancel('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + m.put(self.baseurl + "/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", status_code=204) + self.server.flow_runs.cancel("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") - def test_cancel_item(self): + def test_cancel_item(self) -> None: run = TSC.FlowRunItem() - run._id = 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760' + run._id = "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" with requests_mock.mock() as m: - m.put(self.baseurl + '/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', status_code=204) + m.put(self.baseurl + "/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", status_code=204) self.server.flow_runs.cancel(run) - - def test_wait_for_job_finished(self): + def test_wait_for_job_finished(self) -> None: # Waiting for an already finished job, directly returns that job's info response_xml = read_xml_asset(GET_BY_ID_XML) - flow_run_id = 'cc2e652d-4a9b-4476-8c93-b238c45db968' + flow_run_id = "cc2e652d-4a9b-4476-8c93-b238c45db968" with mocked_time(), requests_mock.mock() as m: - m.get('{0}/{1}'.format(self.baseurl, flow_run_id), text=response_xml) + m.get("{0}/{1}".format(self.baseurl, flow_run_id), text=response_xml) flow_run = self.server.flow_runs.wait_for_job(flow_run_id) self.assertEqual(flow_run_id, flow_run.id) self.assertEqual(flow_run.progress, "100") - - def test_wait_for_job_failed(self): + def test_wait_for_job_failed(self) -> None: # Waiting for a failed job raises an exception response_xml = read_xml_asset(GET_BY_ID_FAILED_XML) - flow_run_id = 'c2b35d5a-e130-471a-aec8-7bc5435fe0e7' + flow_run_id = "c2b35d5a-e130-471a-aec8-7bc5435fe0e7" with mocked_time(), requests_mock.mock() as m: - m.get('{0}/{1}'.format(self.baseurl, flow_run_id), text=response_xml) + m.get("{0}/{1}".format(self.baseurl, flow_run_id), text=response_xml) with self.assertRaises(FlowRunFailedException): self.server.flow_runs.wait_for_job(flow_run_id) - - def test_wait_for_job_timeout(self): + def test_wait_for_job_timeout(self) -> None: # Waiting for a job which doesn't terminate will throw an exception response_xml = read_xml_asset(GET_BY_ID_INPROGRESS_XML) - flow_run_id = '71afc22c-9c06-40be-8d0f-4c4166d29e6c' + flow_run_id = "71afc22c-9c06-40be-8d0f-4c4166d29e6c" with mocked_time(), requests_mock.mock() as m: - m.get('{0}/{1}'.format(self.baseurl, flow_run_id), text=response_xml) + m.get("{0}/{1}".format(self.baseurl, flow_run_id), text=response_xml) with self.assertRaises(TimeoutError): self.server.flow_runs.wait_for_job(flow_run_id, timeout=30) diff --git a/test/test_group.py b/test/test_group.py index 082a63ba3..d948090ca 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -1,234 +1,249 @@ # encoding=utf-8 -import unittest import os +import unittest + 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 = 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') -POPULATE_USERS_EMPTY = os.path.join(TEST_ASSET_DIR, 'group_populate_users_empty.xml') -ADD_USER = os.path.join(TEST_ASSET_DIR, 'group_add_user.xml') -ADD_USER_POPULATE = os.path.join(TEST_ASSET_DIR, 'group_users_added.xml') -CREATE_GROUP = os.path.join(TEST_ASSET_DIR, 'group_create.xml') -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') +GET_XML = os.path.join(TEST_ASSET_DIR, "group_get.xml") +POPULATE_USERS = os.path.join(TEST_ASSET_DIR, "group_populate_users.xml") +POPULATE_USERS_EMPTY = os.path.join(TEST_ASSET_DIR, "group_populate_users_empty.xml") +ADD_USER = os.path.join(TEST_ASSET_DIR, "group_add_user.xml") +ADD_USER_POPULATE = os.path.join(TEST_ASSET_DIR, "group_users_added.xml") +CREATE_GROUP = os.path.join(TEST_ASSET_DIR, "group_create.xml") +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") class GroupTests(unittest.TestCase): - def setUp(self): - self.server = TSC.Server('http://test') + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) # Fake signin - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.baseurl = self.server.groups.baseurl - def test_get(self): - with open(GET_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_get(self) -> None: + with open(GET_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) all_groups, pagination_item = self.server.groups.get() self.assertEqual(3, pagination_item.total_available) - self.assertEqual('ef8b19c0-43b6-11e6-af50-63f5805dbe3c', all_groups[0].id) - self.assertEqual('All Users', all_groups[0].name) - self.assertEqual('local', all_groups[0].domain_name) + self.assertEqual("ef8b19c0-43b6-11e6-af50-63f5805dbe3c", all_groups[0].id) + self.assertEqual("All Users", all_groups[0].name) + self.assertEqual("local", all_groups[0].domain_name) - self.assertEqual('e7833b48-c6f7-47b5-a2a7-36e7dd232758', all_groups[1].id) - self.assertEqual('Another group', all_groups[1].name) - self.assertEqual('local', all_groups[1].domain_name) + self.assertEqual("e7833b48-c6f7-47b5-a2a7-36e7dd232758", all_groups[1].id) + self.assertEqual("Another group", all_groups[1].name) + self.assertEqual("local", all_groups[1].domain_name) - self.assertEqual('86a66d40-f289-472a-83d0-927b0f954dc8', all_groups[2].id) - self.assertEqual('TableauExample', all_groups[2].name) - self.assertEqual('local', all_groups[2].domain_name) + self.assertEqual("86a66d40-f289-472a-83d0-927b0f954dc8", all_groups[2].id) + self.assertEqual("TableauExample", all_groups[2].name) + self.assertEqual("local", all_groups[2].domain_name) - def test_get_before_signin(self): + def test_get_before_signin(self) -> None: self.server._auth_token = None self.assertRaises(TSC.NotSignedInError, self.server.groups.get) - def test_populate_users(self): - with open(POPULATE_USERS, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_populate_users(self) -> None: + with open(POPULATE_USERS, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users?pageNumber=1&pageSize=100', - text=response_xml, complete_qs=True) - single_group = TSC.GroupItem(name='Test Group') - single_group._id = 'e7833b48-c6f7-47b5-a2a7-36e7dd232758' + m.get( + self.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users?pageNumber=1&pageSize=100", + text=response_xml, + complete_qs=True, + ) + single_group = TSC.GroupItem(name="Test Group") + single_group._id = "e7833b48-c6f7-47b5-a2a7-36e7dd232758" self.server.groups.populate_users(single_group) self.assertEqual(1, len(list(single_group.users))) user = list(single_group.users).pop() - self.assertEqual('dd2239f6-ddf1-4107-981a-4cf94e415794', user.id) - self.assertEqual('alice', user.name) - self.assertEqual('Publisher', user.site_role) - self.assertEqual('2016-08-16T23:17:06Z', format_datetime(user.last_login)) + self.assertEqual("dd2239f6-ddf1-4107-981a-4cf94e415794", user.id) + self.assertEqual("alice", user.name) + self.assertEqual("Publisher", user.site_role) + self.assertEqual("2016-08-16T23:17:06Z", format_datetime(user.last_login)) - def test_delete(self): + def test_delete(self) -> None: with requests_mock.mock() as m: - m.delete(self.baseurl + '/e7833b48-c6f7-47b5-a2a7-36e7dd232758', status_code=204) - self.server.groups.delete('e7833b48-c6f7-47b5-a2a7-36e7dd232758') + m.delete(self.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758", status_code=204) + self.server.groups.delete("e7833b48-c6f7-47b5-a2a7-36e7dd232758") - def test_remove_user(self): - with open(POPULATE_USERS, 'rb') as f: - response_xml_populate = f.read().decode('utf-8') + def test_remove_user(self) -> None: + with open(POPULATE_USERS, "rb") as f: + response_xml_populate = f.read().decode("utf-8") - with open(POPULATE_USERS_EMPTY, 'rb') as f: - response_xml_empty = f.read().decode('utf-8') + with open(POPULATE_USERS_EMPTY, "rb") as f: + response_xml_empty = f.read().decode("utf-8") with requests_mock.mock() as m: - url = self.baseurl + '/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users' \ - '/dd2239f6-ddf1-4107-981a-4cf94e415794' + url = self.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users" "/dd2239f6-ddf1-4107-981a-4cf94e415794" m.delete(url, status_code=204) # We register the get endpoint twice. The first time we have 1 user, the second we have 'removed' them. - m.get(self.baseurl + '/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users', text=response_xml_populate) + m.get(self.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users", text=response_xml_populate) - single_group = TSC.GroupItem('test') - single_group._id = 'e7833b48-c6f7-47b5-a2a7-36e7dd232758' + single_group = TSC.GroupItem("test") + single_group._id = "e7833b48-c6f7-47b5-a2a7-36e7dd232758" self.server.groups.populate_users(single_group) self.assertEqual(1, len(list(single_group.users))) - self.server.groups.remove_user(single_group, 'dd2239f6-ddf1-4107-981a-4cf94e415794') + self.server.groups.remove_user(single_group, "dd2239f6-ddf1-4107-981a-4cf94e415794") - m.get(self.baseurl + '/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users', text=response_xml_empty) + m.get(self.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users", text=response_xml_empty) self.assertEqual(0, len(list(single_group.users))) - def test_add_user(self): - with open(ADD_USER, 'rb') as f: - response_xml_add = f.read().decode('utf-8') - with open(ADD_USER_POPULATE, 'rb') as f: - response_xml_populate = f.read().decode('utf-8') + def test_add_user(self) -> None: + with open(ADD_USER, "rb") as f: + response_xml_add = f.read().decode("utf-8") + with open(ADD_USER_POPULATE, "rb") as f: + response_xml_populate = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(self.baseurl + '/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users', text=response_xml_add) - m.get(self.baseurl + '/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users', text=response_xml_populate) - single_group = TSC.GroupItem('test') - single_group._id = 'e7833b48-c6f7-47b5-a2a7-36e7dd232758' + m.post(self.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users", text=response_xml_add) + m.get(self.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users", text=response_xml_populate) + single_group = TSC.GroupItem("test") + single_group._id = "e7833b48-c6f7-47b5-a2a7-36e7dd232758" - self.server.groups.add_user(single_group, '5de011f8-5aa9-4d5b-b991-f462c8dd6bb7') + self.server.groups.add_user(single_group, "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7") self.server.groups.populate_users(single_group) self.assertEqual(1, len(list(single_group.users))) user = list(single_group.users).pop() - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', user.id) - self.assertEqual('testuser', user.name) - self.assertEqual('ServerAdministrator', user.site_role) - - def test_add_user_before_populating(self): - with open(GET_XML, 'rb') as f: - get_xml_response = f.read().decode('utf-8') - with open(ADD_USER, 'rb') as f: - add_user_response = f.read().decode('utf-8') + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", user.id) + self.assertEqual("testuser", user.name) + self.assertEqual("ServerAdministrator", user.site_role) + + def test_add_user_before_populating(self) -> None: + with open(GET_XML, "rb") as f: + get_xml_response = f.read().decode("utf-8") + with open(ADD_USER, "rb") as f: + add_user_response = f.read().decode("utf-8") with requests_mock.mock() as m: m.get(self.baseurl, text=get_xml_response) - m.post('http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/groups/ef8b19c0-43b6-11e6-af50' - '-63f5805dbe3c/users', text=add_user_response) + m.post( + "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/groups/ef8b19c0-43b6-11e6-af50" + "-63f5805dbe3c/users", + text=add_user_response, + ) all_groups, pagination_item = self.server.groups.get() single_group = all_groups[0] - self.server.groups.add_user(single_group, '5de011f8-5aa9-4d5b-b991-f462c8dd6bb7') + self.server.groups.add_user(single_group, "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7") - def test_add_user_missing_user_id(self): - with open(POPULATE_USERS, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_add_user_missing_user_id(self) -> None: + with open(POPULATE_USERS, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users', text=response_xml) - single_group = TSC.GroupItem(name='Test Group') - single_group._id = 'e7833b48-c6f7-47b5-a2a7-36e7dd232758' + m.get(self.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users", text=response_xml) + single_group = TSC.GroupItem(name="Test Group") + single_group._id = "e7833b48-c6f7-47b5-a2a7-36e7dd232758" self.server.groups.populate_users(single_group) - self.assertRaises(ValueError, self.server.groups.add_user, single_group, '') + self.assertRaises(ValueError, self.server.groups.add_user, single_group, "") - def test_add_user_missing_group_id(self): - single_group = TSC.GroupItem('test') - single_group._users = [] - self.assertRaises(TSC.MissingRequiredFieldError, self.server.groups.add_user, single_group, - '5de011f8-5aa9-4d5b-b991-f462c8dd6bb7') + def test_add_user_missing_group_id(self) -> None: + single_group = TSC.GroupItem("test") + self.assertRaises( + TSC.MissingRequiredFieldError, + self.server.groups.add_user, + single_group, + "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", + ) - def test_remove_user_before_populating(self): - with open(GET_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_remove_user_before_populating(self) -> None: + with open(GET_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) - m.delete('http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/groups/ef8b19c0-43b6-11e6-af50' - '-63f5805dbe3c/users/5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', - text='ok') + m.delete( + "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/groups/ef8b19c0-43b6-11e6-af50" + "-63f5805dbe3c/users/5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", + text="ok", + ) all_groups, pagination_item = self.server.groups.get() single_group = all_groups[0] - self.server.groups.remove_user(single_group, '5de011f8-5aa9-4d5b-b991-f462c8dd6bb7') + self.server.groups.remove_user(single_group, "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7") - def test_remove_user_missing_user_id(self): - with open(POPULATE_USERS, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_remove_user_missing_user_id(self) -> None: + with open(POPULATE_USERS, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users', text=response_xml) - single_group = TSC.GroupItem(name='Test Group') - single_group._id = 'e7833b48-c6f7-47b5-a2a7-36e7dd232758' + m.get(self.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users", text=response_xml) + single_group = TSC.GroupItem(name="Test Group") + single_group._id = "e7833b48-c6f7-47b5-a2a7-36e7dd232758" self.server.groups.populate_users(single_group) - self.assertRaises(ValueError, self.server.groups.remove_user, single_group, '') + self.assertRaises(ValueError, self.server.groups.remove_user, single_group, "") - def test_remove_user_missing_group_id(self): - single_group = TSC.GroupItem('test') - single_group._users = [] - self.assertRaises(TSC.MissingRequiredFieldError, self.server.groups.remove_user, single_group, - '5de011f8-5aa9-4d5b-b991-f462c8dd6bb7') + def test_remove_user_missing_group_id(self) -> None: + single_group = TSC.GroupItem("test") + self.assertRaises( + TSC.MissingRequiredFieldError, + self.server.groups.remove_user, + single_group, + "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", + ) - def test_create_group(self): - with open(CREATE_GROUP, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_create_group(self) -> None: + with open(CREATE_GROUP, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=response_xml) - group_to_create = TSC.GroupItem(u'試供品') + group_to_create = TSC.GroupItem("試供品") group = self.server.groups.create(group_to_create) - self.assertEqual(group.name, u'試供品') - self.assertEqual(group.id, '3e4a9ea0-a07a-4fe6-b50f-c345c8c81034') + self.assertEqual(group.name, "試供品") + self.assertEqual(group.id, "3e4a9ea0-a07a-4fe6-b50f-c345c8c81034") - def test_create_ad_group(self): - with open(CREATE_GROUP_AD, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_create_ad_group(self) -> None: + with open(CREATE_GROUP_AD, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=response_xml) - group_to_create = TSC.GroupItem(u'試供品') - group_to_create.domain_name = 'just-has-to-exist' + group_to_create = TSC.GroupItem("試供品") + group_to_create.domain_name = "just-has-to-exist" group = self.server.groups.create_AD_group(group_to_create, False) - self.assertEqual(group.name, u'試供品') - self.assertEqual(group.license_mode, 'onLogin') - self.assertEqual(group.minimum_site_role, 'Creator') - self.assertEqual(group.domain_name, 'active-directory-domain-name') - - def test_create_group_async(self): - with open(CREATE_GROUP_ASYNC, 'rb') as f: - response_xml = f.read().decode('utf-8') + self.assertEqual(group.name, "試供品") + self.assertEqual(group.license_mode, "onLogin") + self.assertEqual(group.minimum_site_role, "Creator") + self.assertEqual(group.domain_name, "active-directory-domain-name") + + def test_create_group_async(self) -> None: + with open(CREATE_GROUP_ASYNC, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=response_xml) - group_to_create = TSC.GroupItem(u'試供品') - group_to_create.domain_name = 'woohoo' + group_to_create = TSC.GroupItem("試供品") + group_to_create.domain_name = "woohoo" job = self.server.groups.create_AD_group(group_to_create, True) - self.assertEqual(job.mode, 'Asynchronous') - self.assertEqual(job.type, 'GroupImport') + self.assertEqual(job.mode, "Asynchronous") + self.assertEqual(job.type, "GroupImport") - def test_update(self): - with open(UPDATE_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_update(self) -> None: + with open(UPDATE_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.put(self.baseurl + '/ef8b19c0-43b6-11e6-af50-63f5805dbe3c', text=response_xml) - group = TSC.GroupItem(name='Test Group') - group._domain_name = 'local' - group._id = 'ef8b19c0-43b6-11e6-af50-63f5805dbe3c' + m.put(self.baseurl + "/ef8b19c0-43b6-11e6-af50-63f5805dbe3c", text=response_xml) + group = TSC.GroupItem(name="Test Group") + group._domain_name = "local" + group._id = "ef8b19c0-43b6-11e6-af50-63f5805dbe3c" group = self.server.groups.update(group) - self.assertEqual('ef8b19c0-43b6-11e6-af50-63f5805dbe3c', group.id) - self.assertEqual('Group updated name', group.name) - self.assertEqual('ExplorerCanPublish', group.minimum_site_role) - self.assertEqual('onLogin', group.license_mode) + self.assertEqual("ef8b19c0-43b6-11e6-af50-63f5805dbe3c", group.id) + self.assertEqual("Group updated name", group.name) + self.assertEqual("ExplorerCanPublish", group.minimum_site_role) + self.assertEqual("onLogin", group.license_mode) # async update is not supported for local groups - def test_update_local_async(self): + def test_update_local_async(self) -> None: group = TSC.GroupItem("myGroup") - group._id = 'ef8b19c0-43b6-11e6-af50-63f5805dbe3c' + group._id = "ef8b19c0-43b6-11e6-af50-63f5805dbe3c" self.assertRaises(ValueError, self.server.groups.update, group, as_job=True) # mimic group returned from server where domain name is set to 'local' diff --git a/test/test_group_model.py b/test/test_group_model.py index 617a5d954..6b79dc18a 100644 --- a/test/test_group_model.py +++ b/test/test_group_model.py @@ -1,4 +1,5 @@ import unittest + import tableauserverclient as TSC diff --git a/test/test_job.py b/test/test_job.py index 70bca996c..6daa16afa 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -1,33 +1,35 @@ -import unittest import os +import unittest from datetime import datetime + import requests_mock + import tableauserverclient as TSC from tableauserverclient.datetime_helpers import utc from tableauserverclient.server.endpoint.exceptions import JobFailedException from ._utils import read_xml_asset, mocked_time -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") -GET_XML = 'job_get.xml' -GET_BY_ID_XML = 'job_get_by_id.xml' -GET_BY_ID_FAILED_XML = 'job_get_by_id_failed.xml' -GET_BY_ID_CANCELLED_XML = 'job_get_by_id_cancelled.xml' -GET_BY_ID_INPROGRESS_XML = 'job_get_by_id_inprogress.xml' +GET_XML = "job_get.xml" +GET_BY_ID_XML = "job_get_by_id.xml" +GET_BY_ID_FAILED_XML = "job_get_by_id_failed.xml" +GET_BY_ID_CANCELLED_XML = "job_get_by_id_cancelled.xml" +GET_BY_ID_INPROGRESS_XML = "job_get_by_id_inprogress.xml" class JobTests(unittest.TestCase): - def setUp(self): - self.server = TSC.Server('http://test') - self.server.version = '3.1' + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) + self.server.version = "3.1" # Fake signin - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.baseurl = self.server.jobs.baseurl - def test_get(self): + def test_get(self) -> None: response_xml = read_xml_asset(GET_XML) with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) @@ -38,70 +40,66 @@ def test_get(self): ended_at = datetime(2018, 5, 22, 13, 0, 45, tzinfo=utc) self.assertEqual(1, pagination_item.total_available) - self.assertEqual('2eef4225-aa0c-41c4-8662-a76d89ed7336', job.id) - self.assertEqual('Success', job.status) - self.assertEqual('50', job.priority) - self.assertEqual('single_subscription_notify', job.type) + self.assertEqual("2eef4225-aa0c-41c4-8662-a76d89ed7336", job.id) + self.assertEqual("Success", job.status) + self.assertEqual("50", job.priority) + self.assertEqual("single_subscription_notify", job.type) self.assertEqual(created_at, job.created_at) self.assertEqual(started_at, job.started_at) self.assertEqual(ended_at, job.ended_at) - def test_get_by_id(self): + def test_get_by_id(self) -> None: response_xml = read_xml_asset(GET_BY_ID_XML) - job_id = '2eef4225-aa0c-41c4-8662-a76d89ed7336' + job_id = "2eef4225-aa0c-41c4-8662-a76d89ed7336" with requests_mock.mock() as m: - m.get('{0}/{1}'.format(self.baseurl, job_id), text=response_xml) + m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) job = self.server.jobs.get_by_id(job_id) self.assertEqual(job_id, job.id) - self.assertListEqual(job.notes, ['Job detail notes']) + self.assertListEqual(job.notes, ["Job detail notes"]) - def test_get_before_signin(self): + def test_get_before_signin(self) -> None: self.server._auth_token = None self.assertRaises(TSC.NotSignedInError, self.server.jobs.get) - def test_cancel_id(self): + def test_cancel_id(self) -> None: with requests_mock.mock() as m: - m.put(self.baseurl + '/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', status_code=204) - self.server.jobs.cancel('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + m.put(self.baseurl + "/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", status_code=204) + self.server.jobs.cancel("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") - def test_cancel_item(self): + def test_cancel_item(self) -> None: created_at = datetime(2018, 5, 22, 13, 0, 29, tzinfo=utc) started_at = datetime(2018, 5, 22, 13, 0, 37, tzinfo=utc) - job = TSC.JobItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'backgroundJob', - 0, created_at, started_at, None, 0) + job = TSC.JobItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "backgroundJob", "0", created_at, started_at, None, 0) with requests_mock.mock() as m: - m.put(self.baseurl + '/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', status_code=204) + m.put(self.baseurl + "/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", status_code=204) self.server.jobs.cancel(job) - - def test_wait_for_job_finished(self): + def test_wait_for_job_finished(self) -> None: # Waiting for an already finished job, directly returns that job's info response_xml = read_xml_asset(GET_BY_ID_XML) - job_id = '2eef4225-aa0c-41c4-8662-a76d89ed7336' + job_id = "2eef4225-aa0c-41c4-8662-a76d89ed7336" with mocked_time(), requests_mock.mock() as m: - m.get('{0}/{1}'.format(self.baseurl, job_id), text=response_xml) + m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) job = self.server.jobs.wait_for_job(job_id) self.assertEqual(job_id, job.id) - self.assertListEqual(job.notes, ['Job detail notes']) - + self.assertListEqual(job.notes, ["Job detail notes"]) - def test_wait_for_job_failed(self): + def test_wait_for_job_failed(self) -> None: # Waiting for a failed job raises an exception response_xml = read_xml_asset(GET_BY_ID_FAILED_XML) - job_id = '77d5e57a-2517-479f-9a3c-a32025f2b64d' + job_id = "77d5e57a-2517-479f-9a3c-a32025f2b64d" with mocked_time(), requests_mock.mock() as m: - m.get('{0}/{1}'.format(self.baseurl, job_id), text=response_xml) + m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) with self.assertRaises(JobFailedException): self.server.jobs.wait_for_job(job_id) - - def test_wait_for_job_timeout(self): + def test_wait_for_job_timeout(self) -> None: # Waiting for a job which doesn't terminate will throw an exception response_xml = read_xml_asset(GET_BY_ID_INPROGRESS_XML) - job_id = '77d5e57a-2517-479f-9a3c-a32025f2b64d' + job_id = "77d5e57a-2517-479f-9a3c-a32025f2b64d" with mocked_time(), requests_mock.mock() as m: - m.get('{0}/{1}'.format(self.baseurl, job_id), text=response_xml) + m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) with self.assertRaises(TimeoutError): self.server.jobs.wait_for_job(job_id, timeout=30) diff --git a/test/test_metadata.py b/test/test_metadata.py index 1c0846d73..1dc9cf1c6 100644 --- a/test/test_metadata.py +++ b/test/test_metadata.py @@ -1,98 +1,102 @@ -import unittest -import os.path import json +import os.path +import unittest + import requests_mock -import tableauserverclient as TSC +import tableauserverclient as TSC from tableauserverclient.server.endpoint.exceptions import GraphQLError -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") -METADATA_QUERY_SUCCESS = os.path.join(TEST_ASSET_DIR, 'metadata_query_success.json') -METADATA_QUERY_ERROR = os.path.join(TEST_ASSET_DIR, 'metadata_query_error.json') -EXPECTED_PAGED_DICT = os.path.join(TEST_ASSET_DIR, 'metadata_query_expected_dict.dict') +METADATA_QUERY_SUCCESS = os.path.join(TEST_ASSET_DIR, "metadata_query_success.json") +METADATA_QUERY_ERROR = os.path.join(TEST_ASSET_DIR, "metadata_query_error.json") +EXPECTED_PAGED_DICT = os.path.join(TEST_ASSET_DIR, "metadata_query_expected_dict.dict") -METADATA_PAGE_1 = os.path.join(TEST_ASSET_DIR, 'metadata_paged_1.json') -METADATA_PAGE_2 = os.path.join(TEST_ASSET_DIR, 'metadata_paged_2.json') -METADATA_PAGE_3 = os.path.join(TEST_ASSET_DIR, 'metadata_paged_3.json') +METADATA_PAGE_1 = os.path.join(TEST_ASSET_DIR, "metadata_paged_1.json") +METADATA_PAGE_2 = os.path.join(TEST_ASSET_DIR, "metadata_paged_2.json") +METADATA_PAGE_3 = os.path.join(TEST_ASSET_DIR, "metadata_paged_3.json") -EXPECTED_DICT = {'publishedDatasources': - [{'id': '01cf92b2-2d17-b656-fc48-5c25ef6d5352', 'name': 'Batters (TestV1)'}, - {'id': '020ae1cd-c356-f1ad-a846-b0094850d22a', 'name': 'SharePoint_List_sharepoint2010.test.tsi.lan'}, - {'id': '061493a0-c3b2-6f39-d08c-bc3f842b44af', 'name': 'Batters_mongodb'}, - {'id': '089fe515-ad2f-89bc-94bd-69f55f69a9c2', 'name': 'Sample - Superstore'}]} +EXPECTED_DICT = { + "publishedDatasources": [ + {"id": "01cf92b2-2d17-b656-fc48-5c25ef6d5352", "name": "Batters (TestV1)"}, + {"id": "020ae1cd-c356-f1ad-a846-b0094850d22a", "name": "SharePoint_List_sharepoint2010.test.tsi.lan"}, + {"id": "061493a0-c3b2-6f39-d08c-bc3f842b44af", "name": "Batters_mongodb"}, + {"id": "089fe515-ad2f-89bc-94bd-69f55f69a9c2", "name": "Sample - Superstore"}, + ] +} -EXPECTED_DICT_ERROR = [ - { - "message": "Reached time limit of PT5S for query execution.", - "path": None, - "extensions": None - } -] +EXPECTED_DICT_ERROR = [{"message": "Reached time limit of PT5S for query execution.", "path": None, "extensions": None}] class MetadataTests(unittest.TestCase): def setUp(self): - self.server = TSC.Server('http://test') + self.server = TSC.Server("http://test", False) self.baseurl = self.server.metadata.baseurl self.server.version = "3.5" - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" def test_metadata_query(self): - with open(METADATA_QUERY_SUCCESS, 'rb') as f: + with open(METADATA_QUERY_SUCCESS, "rb") as f: response_json = json.loads(f.read().decode()) with requests_mock.mock() as m: m.post(self.baseurl, json=response_json) - actual = self.server.metadata.query('fake query') + actual = self.server.metadata.query("fake query") - datasources = actual['data'] + datasources = actual["data"] self.assertDictEqual(EXPECTED_DICT, datasources) def test_paged_metadata_query(self): - with open(EXPECTED_PAGED_DICT, 'rb') as f: + with open(EXPECTED_PAGED_DICT, "rb") as f: expected = eval(f.read()) # prepare the 3 pages of results - with open(METADATA_PAGE_1, 'rb') as f: + with open(METADATA_PAGE_1, "rb") as f: result_1 = f.read().decode() - with open(METADATA_PAGE_2, 'rb') as f: + with open(METADATA_PAGE_2, "rb") as f: result_2 = f.read().decode() - with open(METADATA_PAGE_3, 'rb') as f: + with open(METADATA_PAGE_3, "rb") as f: result_3 = f.read().decode() with requests_mock.mock() as m: - m.post(self.baseurl, [{'text': result_1, 'status_code': 200}, - {'text': result_2, 'status_code': 200}, - {'text': result_3, 'status_code': 200}]) + m.post( + self.baseurl, + [ + {"text": result_1, "status_code": 200}, + {"text": result_2, "status_code": 200}, + {"text": result_3, "status_code": 200}, + ], + ) # validation checks for endCursor and hasNextPage, # but the query text doesn't matter for the test - actual = self.server.metadata.paginated_query('fake query endCursor hasNextPage', - variables={'first': 1, 'afterToken': None}) + actual = self.server.metadata.paginated_query( + "fake query endCursor hasNextPage", variables={"first": 1, "afterToken": None} + ) self.assertDictEqual(expected, actual) def test_metadata_query_ignore_error(self): - with open(METADATA_QUERY_ERROR, 'rb') as f: + with open(METADATA_QUERY_ERROR, "rb") as f: response_json = json.loads(f.read().decode()) with requests_mock.mock() as m: m.post(self.baseurl, json=response_json) - actual = self.server.metadata.query('fake query') - datasources = actual['data'] + actual = self.server.metadata.query("fake query") + datasources = actual["data"] - self.assertNotEqual(actual.get('errors', None), None) - self.assertListEqual(EXPECTED_DICT_ERROR, actual['errors']) + self.assertNotEqual(actual.get("errors", None), None) + self.assertListEqual(EXPECTED_DICT_ERROR, actual["errors"]) self.assertDictEqual(EXPECTED_DICT, datasources) def test_metadata_query_abort_on_error(self): - with open(METADATA_QUERY_ERROR, 'rb') as f: + with open(METADATA_QUERY_ERROR, "rb") as f: response_json = json.loads(f.read().decode()) with requests_mock.mock() as m: m.post(self.baseurl, json=response_json) with self.assertRaises(GraphQLError) as e: - self.server.metadata.query('fake query', abort_on_error=True) + self.server.metadata.query("fake query", abort_on_error=True) self.assertListEqual(e.error, EXPECTED_DICT_ERROR) diff --git a/test/test_metrics.py b/test/test_metrics.py new file mode 100644 index 000000000..7628abb1a --- /dev/null +++ b/test/test_metrics.py @@ -0,0 +1,105 @@ +import unittest +import requests_mock +from pathlib import Path + +import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import format_datetime + +assets = Path(__file__).parent / "assets" +METRICS_GET = assets / "metrics_get.xml" +METRICS_GET_BY_ID = assets / "metrics_get_by_id.xml" +METRICS_UPDATE = assets / "metrics_update.xml" + + +class TestMetrics(unittest.TestCase): + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) + + # Fake signin + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + self.server.version = "3.9" + + self.baseurl = self.server.metrics.baseurl + + def test_metrics_get(self) -> None: + with requests_mock.mock() as m: + m.get(self.baseurl, text=METRICS_GET.read_text()) + all_metrics, pagination_item = self.server.metrics.get() + + self.assertEqual(len(all_metrics), 2) + self.assertEqual(pagination_item.total_available, 27) + self.assertEqual(all_metrics[0].id, "6561daa3-20e8-407f-ba09-709b178c0b4a") + self.assertEqual(all_metrics[0].name, "Example metric") + self.assertEqual(all_metrics[0].description, "Description of my metric.") + self.assertEqual(all_metrics[0].webpage_url, "https://test/#/site/site-name/metrics/3") + self.assertEqual(format_datetime(all_metrics[0].created_at), "2020-01-02T01:02:03Z") + self.assertEqual(format_datetime(all_metrics[0].updated_at), "2020-01-02T01:02:03Z") + self.assertEqual(all_metrics[0].suspended, True) + self.assertEqual(all_metrics[0].project_id, "32e79edb-6cfd-47dc-ad79-e8ec2fbb1d33") + self.assertEqual(all_metrics[0].project_name, "Default") + self.assertEqual(all_metrics[0].owner_id, "32e79edb-6cfd-47dc-ad79-e8ec2fbb1d33") + self.assertEqual(all_metrics[0].view_id, "29dae0cd-1862-4a20-a638-e2c2dfa682d4") + self.assertEqual(len(all_metrics[0].tags), 0) + + self.assertEqual(all_metrics[1].id, "721760d9-0aa4-4029-87ae-371c956cea07") + self.assertEqual(all_metrics[1].name, "Another Example metric") + self.assertEqual(all_metrics[1].description, "Description of another metric.") + self.assertEqual(all_metrics[1].webpage_url, "https://test/#/site/site-name/metrics/4") + self.assertEqual(format_datetime(all_metrics[1].created_at), "2020-01-03T01:02:03Z") + self.assertEqual(format_datetime(all_metrics[1].updated_at), "2020-01-04T01:02:03Z") + self.assertEqual(all_metrics[1].suspended, False) + self.assertEqual(all_metrics[1].project_id, "486e0de0-2258-45bd-99cf-b62013e19f4e") + self.assertEqual(all_metrics[1].project_name, "Assets") + self.assertEqual(all_metrics[1].owner_id, "1bbbc2b9-847d-443c-9a1f-dbcf112b8814") + self.assertEqual(all_metrics[1].view_id, "7dbfdb63-a6ca-4723-93ee-4fefc71992d3") + self.assertEqual(len(all_metrics[1].tags), 2) + self.assertIn("Test", all_metrics[1].tags) + self.assertIn("Asset", all_metrics[1].tags) + + def test_metrics_get_by_id(self) -> None: + luid = "6561daa3-20e8-407f-ba09-709b178c0b4a" + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{luid}", text=METRICS_GET_BY_ID.read_text()) + metric = self.server.metrics.get_by_id(luid) + + self.assertEqual(metric.id, "6561daa3-20e8-407f-ba09-709b178c0b4a") + self.assertEqual(metric.name, "Example metric") + self.assertEqual(metric.description, "Description of my metric.") + self.assertEqual(metric.webpage_url, "https://test/#/site/site-name/metrics/3") + self.assertEqual(format_datetime(metric.created_at), "2020-01-02T01:02:03Z") + self.assertEqual(format_datetime(metric.updated_at), "2020-01-02T01:02:03Z") + self.assertEqual(metric.suspended, True) + self.assertEqual(metric.project_id, "32e79edb-6cfd-47dc-ad79-e8ec2fbb1d33") + self.assertEqual(metric.project_name, "Default") + self.assertEqual(metric.owner_id, "32e79edb-6cfd-47dc-ad79-e8ec2fbb1d33") + self.assertEqual(metric.view_id, "29dae0cd-1862-4a20-a638-e2c2dfa682d4") + self.assertEqual(len(metric.tags), 0) + + def test_metrics_delete(self) -> None: + luid = "6561daa3-20e8-407f-ba09-709b178c0b4a" + with requests_mock.mock() as m: + m.delete(f"{self.baseurl}/{luid}") + self.server.metrics.delete(luid) + + def test_metrics_update(self) -> None: + luid = "6561daa3-20e8-407f-ba09-709b178c0b4a" + metric = TSC.MetricItem() + metric._id = luid + + with requests_mock.mock() as m: + m.put(f"{self.baseurl}/{luid}", text=METRICS_UPDATE.read_text()) + metric = self.server.metrics.update(metric) + + self.assertEqual(metric.id, "6561daa3-20e8-407f-ba09-709b178c0b4a") + self.assertEqual(metric.name, "Example metric") + self.assertEqual(metric.description, "Description of my metric.") + self.assertEqual(metric.webpage_url, "https://test/#/site/site-name/metrics/3") + self.assertEqual(format_datetime(metric.created_at), "2020-01-02T01:02:03Z") + self.assertEqual(format_datetime(metric.updated_at), "2020-01-02T01:02:03Z") + self.assertEqual(metric.suspended, True) + self.assertEqual(metric.project_id, "32e79edb-6cfd-47dc-ad79-e8ec2fbb1d33") + self.assertEqual(metric.project_name, "Default") + self.assertEqual(metric.owner_id, "32e79edb-6cfd-47dc-ad79-e8ec2fbb1d33") + self.assertEqual(metric.view_id, "29dae0cd-1862-4a20-a638-e2c2dfa682d4") + self.assertEqual(len(metric.tags), 0) diff --git a/test/test_pager.py b/test/test_pager.py index 52089180d..b60559b2b 100644 --- a/test/test_pager.py +++ b/test/test_pager.py @@ -1,32 +1,34 @@ -import unittest import os +import unittest + import requests_mock + import tableauserverclient as TSC -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") -GET_XML_PAGE1 = os.path.join(TEST_ASSET_DIR, 'workbook_get_page_1.xml') -GET_XML_PAGE2 = os.path.join(TEST_ASSET_DIR, 'workbook_get_page_2.xml') -GET_XML_PAGE3 = os.path.join(TEST_ASSET_DIR, 'workbook_get_page_3.xml') +GET_XML_PAGE1 = os.path.join(TEST_ASSET_DIR, "workbook_get_page_1.xml") +GET_XML_PAGE2 = os.path.join(TEST_ASSET_DIR, "workbook_get_page_2.xml") +GET_XML_PAGE3 = os.path.join(TEST_ASSET_DIR, "workbook_get_page_3.xml") class PagerTests(unittest.TestCase): def setUp(self): - self.server = TSC.Server('http://test') + 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.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.baseurl = self.server.workbooks.baseurl def test_pager_with_no_options(self): - with open(GET_XML_PAGE1, 'rb') as f: - page_1 = f.read().decode('utf-8') - with open(GET_XML_PAGE2, 'rb') as f: - page_2 = f.read().decode('utf-8') - with open(GET_XML_PAGE3, 'rb') as f: - page_3 = f.read().decode('utf-8') + with open(GET_XML_PAGE1, "rb") as f: + page_1 = f.read().decode("utf-8") + with open(GET_XML_PAGE2, "rb") as f: + page_2 = f.read().decode("utf-8") + with open(GET_XML_PAGE3, "rb") as f: + page_3 = f.read().decode("utf-8") with requests_mock.mock() as m: # Register Pager with default request options m.get(self.baseurl, text=page_1) @@ -42,17 +44,17 @@ def test_pager_with_no_options(self): # Let's check that workbook items aren't duplicates wb1, wb2, wb3 = workbooks - self.assertEqual(wb1.name, 'Page1Workbook') - self.assertEqual(wb2.name, 'Page2Workbook') - self.assertEqual(wb3.name, 'Page3Workbook') + self.assertEqual(wb1.name, "Page1Workbook") + self.assertEqual(wb2.name, "Page2Workbook") + self.assertEqual(wb3.name, "Page3Workbook") def test_pager_with_options(self): - with open(GET_XML_PAGE1, 'rb') as f: - page_1 = f.read().decode('utf-8') - with open(GET_XML_PAGE2, 'rb') as f: - page_2 = f.read().decode('utf-8') - with open(GET_XML_PAGE3, 'rb') as f: - page_3 = f.read().decode('utf-8') + with open(GET_XML_PAGE1, "rb") as f: + page_1 = f.read().decode("utf-8") + with open(GET_XML_PAGE2, "rb") as f: + page_2 = f.read().decode("utf-8") + with open(GET_XML_PAGE3, "rb") as f: + page_3 = f.read().decode("utf-8") with requests_mock.mock() as m: # Register Pager with some pages m.get(self.baseurl + "?pageNumber=1&pageSize=1", complete_qs=True, text=page_1) @@ -67,17 +69,17 @@ def test_pager_with_options(self): # Check that the workbooks are the 2 we think they should be wb2, wb3 = workbooks - self.assertEqual(wb2.name, 'Page2Workbook') - self.assertEqual(wb3.name, 'Page3Workbook') + self.assertEqual(wb2.name, "Page2Workbook") + self.assertEqual(wb3.name, "Page3Workbook") # Starting on 1 with pagesize of 3 should get all 3 opts = TSC.RequestOptions(1, 3) workbooks = list(TSC.Pager(self.server.workbooks, opts)) self.assertTrue(len(workbooks) == 3) wb1, wb2, wb3 = workbooks - self.assertEqual(wb1.name, 'Page1Workbook') - self.assertEqual(wb2.name, 'Page2Workbook') - self.assertEqual(wb3.name, 'Page3Workbook') + self.assertEqual(wb1.name, "Page1Workbook") + self.assertEqual(wb2.name, "Page2Workbook") + self.assertEqual(wb3.name, "Page3Workbook") # Starting on 3 with pagesize of 1 should get the last item opts = TSC.RequestOptions(3, 1) @@ -85,4 +87,4 @@ def test_pager_with_options(self): self.assertTrue(len(workbooks) == 1) # Should have the last workbook wb3 = workbooks.pop() - self.assertEqual(wb3.name, 'Page3Workbook') + self.assertEqual(wb3.name, "Page3Workbook") diff --git a/test/test_project.py b/test/test_project.py index be43b063e..1d210eeb1 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -1,220 +1,231 @@ -import unittest import os +import unittest + import requests_mock -import tableauserverclient as TSC -from ._utils import read_xml_asset, read_xml_assets, asset +import tableauserverclient as TSC +from ._utils import read_xml_asset, asset -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") -GET_XML = asset('project_get.xml') -UPDATE_XML = asset('project_update.xml') -SET_CONTENT_PERMISSIONS_XML = asset('project_content_permission.xml') -CREATE_XML = asset('project_create.xml') -POPULATE_PERMISSIONS_XML = 'project_populate_permissions.xml' -POPULATE_WORKBOOK_DEFAULT_PERMISSIONS_XML = 'project_populate_workbook_default_permissions.xml' -UPDATE_DATASOURCE_DEFAULT_PERMISSIONS_XML = 'project_update_datasource_default_permissions.xml' +GET_XML = asset("project_get.xml") +UPDATE_XML = asset("project_update.xml") +SET_CONTENT_PERMISSIONS_XML = asset("project_content_permission.xml") +CREATE_XML = asset("project_create.xml") +POPULATE_PERMISSIONS_XML = "project_populate_permissions.xml" +POPULATE_WORKBOOK_DEFAULT_PERMISSIONS_XML = "project_populate_workbook_default_permissions.xml" +UPDATE_DATASOURCE_DEFAULT_PERMISSIONS_XML = "project_update_datasource_default_permissions.xml" class ProjectTests(unittest.TestCase): - def setUp(self): - self.server = TSC.Server('http://test') + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) # Fake signin - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.baseurl = self.server.projects.baseurl - def test_get(self): - with open(GET_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_get(self) -> None: + with open(GET_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) all_projects, pagination_item = self.server.projects.get() self.assertEqual(3, pagination_item.total_available) - self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', all_projects[0].id) - self.assertEqual('default', all_projects[0].name) - self.assertEqual('The default project that was automatically created by Tableau.', - all_projects[0].description) - self.assertEqual('ManagedByOwner', all_projects[0].content_permissions) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", all_projects[0].id) + self.assertEqual("default", all_projects[0].name) + self.assertEqual("The default project that was automatically created by Tableau.", all_projects[0].description) + self.assertEqual("ManagedByOwner", all_projects[0].content_permissions) self.assertEqual(None, all_projects[0].parent_id) - self.assertEqual('dd2239f6-ddf1-4107-981a-4cf94e415794', all_projects[0].owner_id) + self.assertEqual("dd2239f6-ddf1-4107-981a-4cf94e415794", all_projects[0].owner_id) - self.assertEqual('1d0304cd-3796-429f-b815-7258370b9b74', all_projects[1].id) - self.assertEqual('Tableau', all_projects[1].name) - self.assertEqual('ManagedByOwner', all_projects[1].content_permissions) + self.assertEqual("1d0304cd-3796-429f-b815-7258370b9b74", all_projects[1].id) + self.assertEqual("Tableau", all_projects[1].name) + self.assertEqual("ManagedByOwner", all_projects[1].content_permissions) self.assertEqual(None, all_projects[1].parent_id) - self.assertEqual('2a47bbf8-8900-4ebb-b0a4-2723bd7c46c3', all_projects[1].owner_id) + self.assertEqual("2a47bbf8-8900-4ebb-b0a4-2723bd7c46c3", all_projects[1].owner_id) - self.assertEqual('4cc52973-5e3a-4d1f-a4fb-5b5f73796edf', all_projects[2].id) - self.assertEqual('Tableau > Child 1', all_projects[2].name) - self.assertEqual('ManagedByOwner', all_projects[2].content_permissions) - self.assertEqual('1d0304cd-3796-429f-b815-7258370b9b74', all_projects[2].parent_id) - self.assertEqual('dd2239f6-ddf1-4107-981a-4cf94e415794', all_projects[2].owner_id) + self.assertEqual("4cc52973-5e3a-4d1f-a4fb-5b5f73796edf", all_projects[2].id) + self.assertEqual("Tableau > Child 1", all_projects[2].name) + self.assertEqual("ManagedByOwner", all_projects[2].content_permissions) + self.assertEqual("1d0304cd-3796-429f-b815-7258370b9b74", all_projects[2].parent_id) + self.assertEqual("dd2239f6-ddf1-4107-981a-4cf94e415794", all_projects[2].owner_id) - def test_get_before_signin(self): + def test_get_before_signin(self) -> None: self.server._auth_token = None self.assertRaises(TSC.NotSignedInError, self.server.projects.get) - def test_delete(self): + def test_delete(self) -> None: with requests_mock.mock() as m: - m.delete(self.baseurl + '/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', status_code=204) - self.server.projects.delete('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + m.delete(self.baseurl + "/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", status_code=204) + self.server.projects.delete("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") - def test_delete_missing_id(self): - self.assertRaises(ValueError, self.server.projects.delete, '') + def test_delete_missing_id(self) -> None: + self.assertRaises(ValueError, self.server.projects.delete, "") - def test_update(self): - with open(UPDATE_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_update(self) -> None: + with open(UPDATE_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.put(self.baseurl + '/1d0304cd-3796-429f-b815-7258370b9b74', text=response_xml) - single_project = TSC.ProjectItem(name='Test Project', - content_permissions='LockedToProject', - description='Project created for testing', - parent_id='9a8f2265-70f3-4494-96c5-e5949d7a1120') - single_project._id = '1d0304cd-3796-429f-b815-7258370b9b74' + m.put(self.baseurl + "/1d0304cd-3796-429f-b815-7258370b9b74", text=response_xml) + single_project = TSC.ProjectItem( + name="Test Project", + content_permissions="LockedToProject", + description="Project created for testing", + parent_id="9a8f2265-70f3-4494-96c5-e5949d7a1120", + ) + single_project._id = "1d0304cd-3796-429f-b815-7258370b9b74" single_project = self.server.projects.update(single_project) - self.assertEqual('1d0304cd-3796-429f-b815-7258370b9b74', single_project.id) - self.assertEqual('Test Project', single_project.name) - 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("1d0304cd-3796-429f-b815-7258370b9b74", single_project.id) + self.assertEqual("Test Project", single_project.name) + 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) - def test_content_permission_locked_to_project_without_nested(self): - with open(SET_CONTENT_PERMISSIONS_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_content_permission_locked_to_project_without_nested(self) -> None: + with open(SET_CONTENT_PERMISSIONS_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.put(self.baseurl + '/cb3759e5-da4a-4ade-b916-7e2b4ea7ec86', text=response_xml) - project_item = TSC.ProjectItem(name='Test Project Permissions', - content_permissions='LockedToProjectWithoutNested', - description='Project created for testing', - parent_id='7687bc43-a543-42f3-b86f-80caed03a813') - project_item._id = 'cb3759e5-da4a-4ade-b916-7e2b4ea7ec86' + m.put(self.baseurl + "/cb3759e5-da4a-4ade-b916-7e2b4ea7ec86", text=response_xml) + project_item = TSC.ProjectItem( + name="Test Project Permissions", + content_permissions="LockedToProjectWithoutNested", + description="Project created for testing", + parent_id="7687bc43-a543-42f3-b86f-80caed03a813", + ) + project_item._id = "cb3759e5-da4a-4ade-b916-7e2b4ea7ec86" project_item = self.server.projects.update(project_item) - self.assertEqual('cb3759e5-da4a-4ade-b916-7e2b4ea7ec86', project_item.id) - self.assertEqual('Test Project Permissions', project_item.name) - self.assertEqual('Project created for testing', project_item.description) - self.assertEqual('LockedToProjectWithoutNested', project_item.content_permissions) - self.assertEqual('7687bc43-a543-42f3-b86f-80caed03a813', project_item.parent_id) + self.assertEqual("cb3759e5-da4a-4ade-b916-7e2b4ea7ec86", project_item.id) + self.assertEqual("Test Project Permissions", project_item.name) + self.assertEqual("Project created for testing", project_item.description) + self.assertEqual("LockedToProjectWithoutNested", project_item.content_permissions) + self.assertEqual("7687bc43-a543-42f3-b86f-80caed03a813", project_item.parent_id) - def test_update_datasource_default_permission(self): + def test_update_datasource_default_permission(self) -> None: response_xml = read_xml_asset(UPDATE_DATASOURCE_DEFAULT_PERMISSIONS_XML) with requests_mock.mock() as m: - m.put(self.baseurl + '/b4065286-80f0-11ea-af1b-cb7191f48e45/default-permissions/datasources', - text=response_xml) - project = TSC.ProjectItem('test-project') - project._id = 'b4065286-80f0-11ea-af1b-cb7191f48e45' + m.put( + self.baseurl + "/b4065286-80f0-11ea-af1b-cb7191f48e45/default-permissions/datasources", + text=response_xml, + ) + project = TSC.ProjectItem("test-project") + project._id = "b4065286-80f0-11ea-af1b-cb7191f48e45" - group = TSC.GroupItem('test-group') - group._id = 'b4488bce-80f0-11ea-af1c-976d0c1dab39' + group = TSC.GroupItem("test-group") + group._id = "b4488bce-80f0-11ea-af1c-976d0c1dab39" capabilities = {TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny} - rules = [TSC.PermissionsRule( - grantee=group, - capabilities=capabilities - )] + rules = [TSC.PermissionsRule(grantee=group.to_reference(), capabilities=capabilities)] new_rules = self.server.projects.update_datasource_default_permissions(project, rules) - self.assertEqual('b4488bce-80f0-11ea-af1c-976d0c1dab39', new_rules[0].grantee.id) + self.assertEqual("b4488bce-80f0-11ea-af1c-976d0c1dab39", new_rules[0].grantee.id) updated_capabilities = new_rules[0].capabilities self.assertEqual(4, len(updated_capabilities)) - self.assertEqual('Deny', updated_capabilities['ExportXml']) - self.assertEqual('Allow', updated_capabilities['Read']) - self.assertEqual('Allow', updated_capabilities['Write']) - self.assertEqual('Allow', updated_capabilities['Connect']) + self.assertEqual("Deny", updated_capabilities["ExportXml"]) + self.assertEqual("Allow", updated_capabilities["Read"]) + self.assertEqual("Allow", updated_capabilities["Write"]) + self.assertEqual("Allow", updated_capabilities["Connect"]) - def test_update_missing_id(self): - single_project = TSC.ProjectItem('test') + def test_update_missing_id(self) -> None: + single_project = TSC.ProjectItem("test") self.assertRaises(TSC.MissingRequiredFieldError, self.server.projects.update, single_project) - def test_create(self): - with open(CREATE_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_create(self) -> None: + + with open(CREATE_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_project = TSC.ProjectItem(name='Test Project', description='Project created for testing') - new_project.content_permissions = 'ManagedByOwner' - new_project.parent_id = '9a8f2265-70f3-4494-96c5-e5949d7a1120' + new_project = TSC.ProjectItem(name="Test Project", description="Project created for testing") + new_project.content_permissions = "ManagedByOwner" + new_project.parent_id = "9a8f2265-70f3-4494-96c5-e5949d7a1120" new_project = self.server.projects.create(new_project) - self.assertEqual('ccbea03f-77c4-4209-8774-f67bc59c3cef', new_project.id) - self.assertEqual('Test Project', new_project.name) - self.assertEqual('Project created for testing', new_project.description) - self.assertEqual('ManagedByOwner', new_project.content_permissions) - self.assertEqual('9a8f2265-70f3-4494-96c5-e5949d7a1120', new_project.parent_id) + self.assertEqual("ccbea03f-77c4-4209-8774-f67bc59c3cef", new_project.id) + self.assertEqual("Test Project", new_project.name) + self.assertEqual("Project created for testing", new_project.description) + self.assertEqual("ManagedByOwner", new_project.content_permissions) + self.assertEqual("9a8f2265-70f3-4494-96c5-e5949d7a1120", new_project.parent_id) - def test_create_missing_name(self): - self.assertRaises(ValueError, TSC.ProjectItem, '') + def test_create_missing_name(self) -> None: + self.assertRaises(ValueError, TSC.ProjectItem, "") - def test_populate_permissions(self): - with open(asset(POPULATE_PERMISSIONS_XML), 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_populate_permissions(self) -> None: + with open(asset(POPULATE_PERMISSIONS_XML), "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions', text=response_xml) - single_project = TSC.ProjectItem('Project3') - single_project._id = '0448d2ed-590d-4fa0-b272-a2a8a24555b5' + m.get(self.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions", text=response_xml) + single_project = TSC.ProjectItem("Project3") + single_project._id = "0448d2ed-590d-4fa0-b272-a2a8a24555b5" self.server.projects.populate_permissions(single_project) permissions = single_project.permissions - self.assertEqual(permissions[0].grantee.tag_name, 'group') - self.assertEqual(permissions[0].grantee.id, 'c8f2773a-c83a-11e8-8c8f-33e6d787b506') - self.assertDictEqual(permissions[0].capabilities, { - TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, - }) + self.assertEqual(permissions[0].grantee.tag_name, "group") + self.assertEqual(permissions[0].grantee.id, "c8f2773a-c83a-11e8-8c8f-33e6d787b506") + self.assertDictEqual( + permissions[0].capabilities, + { + TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + }, + ) - def test_populate_workbooks(self): + def test_populate_workbooks(self) -> None: response_xml = read_xml_asset(POPULATE_WORKBOOK_DEFAULT_PERMISSIONS_XML) with requests_mock.mock() as m: - m.get(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._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' + m.get( + 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._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" self.server.projects.populate_workbook_default_permissions(single_project) permissions = single_project.default_workbook_permissions rule1 = permissions.pop() - self.assertEqual('c8f2773a-c83a-11e8-8c8f-33e6d787b506', rule1.grantee.id) - self.assertEqual('group', rule1.grantee.tag_name) - self.assertDictEqual(rule1.capabilities, { - TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Filter: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.WebAuthoring: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportImage: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, - TSC.Permission.Capability.ShareView: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ViewUnderlyingData: TSC.Permission.Mode.Deny, - TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.AddComment: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Allow, - }) - - def test_delete_permission(self): - with open(asset(POPULATE_PERMISSIONS_XML), 'rb') as f: - response_xml = f.read().decode('utf-8') + self.assertEqual("c8f2773a-c83a-11e8-8c8f-33e6d787b506", rule1.grantee.id) + self.assertEqual("group", rule1.grantee.tag_name) + self.assertDictEqual( + rule1.capabilities, + { + TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Filter: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.WebAuthoring: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportImage: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ShareView: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ViewUnderlyingData: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.AddComment: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Allow, + }, + ) + + def test_delete_permission(self) -> None: + with open(asset(POPULATE_PERMISSIONS_XML), "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions', text=response_xml) + m.get(self.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions", text=response_xml) - single_group = TSC.GroupItem('Group1') - single_group._id = 'c8f2773a-c83a-11e8-8c8f-33e6d787b506' + single_group = TSC.GroupItem("Group1") + single_group._id = "c8f2773a-c83a-11e8-8c8f-33e6d787b506" - single_project = TSC.ProjectItem('Project3') - single_project._id = '0448d2ed-590d-4fa0-b272-a2a8a24555b5' + single_project = TSC.ProjectItem("Project3") + single_project._id = "0448d2ed-590d-4fa0-b272-a2a8a24555b5" self.server.projects.populate_permissions(single_project) permissions = single_project.permissions @@ -226,30 +237,28 @@ def test_delete_permission(self): if permission.grantee.id == single_group._id: capabilities = permission.capabilities - rules = TSC.PermissionsRule( - grantee=single_group, - capabilities=capabilities - ) + rules = TSC.PermissionsRule(grantee=single_group.to_reference(), capabilities=capabilities) - endpoint = '{}/permissions/groups/{}'.format(single_project._id, single_group._id) - m.delete('{}/{}/Read/Allow'.format(self.baseurl, endpoint), status_code=204) - m.delete('{}/{}/Write/Allow'.format(self.baseurl, endpoint), status_code=204) + endpoint = "{}/permissions/groups/{}".format(single_project._id, single_group._id) + m.delete("{}/{}/Read/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/Write/Allow".format(self.baseurl, endpoint), status_code=204) self.server.projects.delete_permission(item=single_project, rules=rules) - def test_delete_workbook_default_permission(self): - with open(asset(POPULATE_WORKBOOK_DEFAULT_PERMISSIONS_XML), 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_delete_workbook_default_permission(self) -> None: + with open(asset(POPULATE_WORKBOOK_DEFAULT_PERMISSIONS_XML), "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/workbooks', - text=response_xml) + m.get( + self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/workbooks", text=response_xml + ) - single_group = TSC.GroupItem('Group1') - single_group._id = 'c8f2773a-c83a-11e8-8c8f-33e6d787b506' + single_group = TSC.GroupItem("Group1") + single_group._id = "c8f2773a-c83a-11e8-8c8f-33e6d787b506" - single_project = TSC.ProjectItem('test', '1d0304cd-3796-429f-b815-7258370b9b74') - single_project._owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' - single_project._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' + single_project = TSC.ProjectItem("test", "1d0304cd-3796-429f-b815-7258370b9b74") + 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) permissions = single_project.default_workbook_permissions @@ -261,39 +270,34 @@ def test_delete_workbook_default_permission(self): TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, TSC.Permission.Capability.AddComment: TSC.Permission.Mode.Allow, - # Interact/Edit TSC.Permission.Capability.Filter: TSC.Permission.Mode.Allow, TSC.Permission.Capability.ViewUnderlyingData: TSC.Permission.Mode.Deny, TSC.Permission.Capability.ShareView: TSC.Permission.Mode.Allow, TSC.Permission.Capability.WebAuthoring: TSC.Permission.Mode.Allow, - # Edit TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Allow, TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Allow, TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, - TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Allow + TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Allow, } - rules = TSC.PermissionsRule( - grantee=single_group, - capabilities=capabilities - ) - - endpoint = '{}/default-permissions/workbooks/groups/{}'.format(single_project._id, single_group._id) - m.delete('{}/{}/Read/Allow'.format(self.baseurl, endpoint), status_code=204) - m.delete('{}/{}/ExportImage/Allow'.format(self.baseurl, endpoint), status_code=204) - m.delete('{}/{}/ExportData/Allow'.format(self.baseurl, endpoint), status_code=204) - m.delete('{}/{}/ViewComments/Allow'.format(self.baseurl, endpoint), status_code=204) - m.delete('{}/{}/AddComment/Allow'.format(self.baseurl, endpoint), status_code=204) - m.delete('{}/{}/Filter/Allow'.format(self.baseurl, endpoint), status_code=204) - m.delete('{}/{}/ViewUnderlyingData/Deny'.format(self.baseurl, endpoint), status_code=204) - m.delete('{}/{}/ShareView/Allow'.format(self.baseurl, endpoint), status_code=204) - m.delete('{}/{}/WebAuthoring/Allow'.format(self.baseurl, endpoint), status_code=204) - m.delete('{}/{}/Write/Allow'.format(self.baseurl, endpoint), status_code=204) - m.delete('{}/{}/ExportXml/Allow'.format(self.baseurl, endpoint), status_code=204) - m.delete('{}/{}/ChangeHierarchy/Allow'.format(self.baseurl, endpoint), status_code=204) - m.delete('{}/{}/Delete/Deny'.format(self.baseurl, endpoint), status_code=204) - m.delete('{}/{}/ChangePermissions/Allow'.format(self.baseurl, endpoint), status_code=204) + rules = TSC.PermissionsRule(grantee=single_group.to_reference(), capabilities=capabilities) + + endpoint = "{}/default-permissions/workbooks/groups/{}".format(single_project._id, single_group._id) + m.delete("{}/{}/Read/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ExportImage/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ExportData/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ViewComments/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/AddComment/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/Filter/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ViewUnderlyingData/Deny".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ShareView/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/WebAuthoring/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/Write/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ExportXml/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ChangeHierarchy/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/Delete/Deny".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ChangePermissions/Allow".format(self.baseurl, endpoint), status_code=204) self.server.projects.delete_workbook_default_permissions(item=single_project, rule=rules) diff --git a/test/test_project_model.py b/test/test_project_model.py index 55cf20b26..a8b96dc4f 100644 --- a/test/test_project_model.py +++ b/test/test_project_model.py @@ -1,4 +1,5 @@ import unittest + import tableauserverclient as TSC diff --git a/test/test_regression_tests.py b/test/test_regression_tests.py index 281f3fbca..58d6329db 100644 --- a/test/test_regression_tests.py +++ b/test/test_regression_tests.py @@ -3,7 +3,7 @@ try: from unittest import mock except ImportError: - import mock + import mock # type: ignore[no-redef] import tableauserverclient.server.request_factory as factory from tableauserverclient.server.endpoint import Endpoint @@ -13,51 +13,50 @@ class BugFix257(unittest.TestCase): def test_empty_request_works(self): result = factory.EmptyRequest().empty_req() - self.assertEqual(b'', result) + self.assertEqual(b"", result) class BugFix273(unittest.TestCase): def test_binary_log_truncated(self): - class FakeResponse(object): - headers = {'Content-Type': 'application/octet-stream'} - content = b'\x1337' * 1000 + headers = {"Content-Type": "application/octet-stream"} + content = b"\x1337" * 1000 status_code = 200 server_response = FakeResponse() - self.assertEqual(Endpoint._safe_to_log(server_response), '[Truncated File Contents]') + self.assertEqual(Endpoint._safe_to_log(server_response), "[Truncated File Contents]") class FileSysHelpers(unittest.TestCase): def test_to_filename(self): invalid = [ "23brhafbjrjhkbbea.txt", - 'a_b_C.txt', - 'windows space.txt', - 'abc#def.txt', - 't@bL3A()', + "a_b_C.txt", + "windows space.txt", + "abc#def.txt", + "t@bL3A()", ] valid = [ "23brhafbjrjhkbbea.txt", - 'a_b_C.txt', - 'windows space.txt', - 'abcdef.txt', - 'tbL3A', + "a_b_C.txt", + "windows space.txt", + "abcdef.txt", + "tbL3A", ] self.assertTrue(all([(to_filename(i) == v) for i, v in zip(invalid, valid)])) def test_make_download_path(self): - no_file_path = (None, 'file.ext') - has_file_path_folder = ('/root/folder/', 'file.ext') - has_file_path_file = ('out', 'file.ext') + no_file_path = (None, "file.ext") + has_file_path_folder = ("/root/folder/", "file.ext") + has_file_path_file = ("outx", "file.ext") - self.assertEqual('file.ext', make_download_path(*no_file_path)) - self.assertEqual('out.ext', make_download_path(*has_file_path_file)) + self.assertEqual("file.ext", make_download_path(*no_file_path)) + self.assertEqual("outx.ext", make_download_path(*has_file_path_file)) - with mock.patch('os.path.isdir') as mocked_isdir: + with mock.patch("os.path.isdir") as mocked_isdir: mocked_isdir.return_value = True - self.assertEqual('/root/folder/file.ext', make_download_path(*has_file_path_folder)) + self.assertEqual("/root/folder/file.ext", make_download_path(*has_file_path_folder)) diff --git a/test/test_request_option.py b/test/test_request_option.py index 37b4fc945..ed8d55bb0 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -1,36 +1,38 @@ -import unittest import os import re -import requests +import unittest + import requests_mock + import tableauserverclient as TSC -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") -PAGINATION_XML = os.path.join(TEST_ASSET_DIR, 'request_option_pagination.xml') -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_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') +PAGINATION_XML = os.path.join(TEST_ASSET_DIR, "request_option_pagination.xml") +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_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") class RequestOptionTests(unittest.TestCase): def setUp(self): - self.server = TSC.Server('http://test') + self.server = TSC.Server("http://test", False) # Fake signin self.server.version = "3.10" - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - self.baseurl = '{0}/{1}'.format(self.server.sites.baseurl, self.server._site_id) + self.baseurl = "{0}/{1}".format(self.server.sites.baseurl, self.server._site_id) def test_pagination(self): - with open(PAGINATION_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(PAGINATION_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/views?pageNumber=1&pageSize=10', text=response_xml) + m.get(self.baseurl + "/views?pageNumber=1&pageSize=10", text=response_xml) req_option = TSC.RequestOptions().page_size(10) all_views, pagination_item = self.server.views.get(req_option) @@ -40,10 +42,10 @@ def test_pagination(self): self.assertEqual(10, len(all_views)) def test_page_number(self): - with open(PAGE_NUMBER_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(PAGE_NUMBER_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/views?pageNumber=3', text=response_xml) + m.get(self.baseurl + "/views?pageNumber=3", text=response_xml) req_option = TSC.RequestOptions().page_number(3) all_views, pagination_item = self.server.views.get(req_option) @@ -53,10 +55,10 @@ def test_page_number(self): self.assertEqual(10, len(all_views)) def test_page_size(self): - with open(PAGE_SIZE_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(PAGE_SIZE_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/views?pageSize=5', text=response_xml) + m.get(self.baseurl + "/views?pageSize=5", text=response_xml) req_option = TSC.RequestOptions().page_size(5) all_views, pagination_item = self.server.views.get(req_option) @@ -66,74 +68,86 @@ def test_page_size(self): self.assertEqual(5, len(all_views)) def test_filter_equals(self): - with open(FILTER_EQUALS, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(FILTER_EQUALS, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/workbooks?filter=name:eq:RESTAPISample', text=response_xml) + m.get(self.baseurl + "/workbooks?filter=name:eq:RESTAPISample", text=response_xml) req_option = TSC.RequestOptions() - req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Operator.Equals, 'RESTAPISample')) + req_option.filter.add( + TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, "RESTAPISample") + ) matching_workbooks, pagination_item = self.server.workbooks.get(req_option) self.assertEqual(2, pagination_item.total_available) - self.assertEqual('RESTAPISample', matching_workbooks[0].name) - self.assertEqual('RESTAPISample', matching_workbooks[1].name) + self.assertEqual("RESTAPISample", matching_workbooks[0].name) + self.assertEqual("RESTAPISample", matching_workbooks[1].name) def test_filter_equals_shorthand(self): - with open(FILTER_EQUALS, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(FILTER_EQUALS, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/workbooks?filter=name:eq:RESTAPISample', text=response_xml) - matching_workbooks = self.server.workbooks.filter(name='RESTAPISample').order_by("name") + m.get(self.baseurl + "/workbooks?filter=name:eq:RESTAPISample", text=response_xml) + matching_workbooks = self.server.workbooks.filter(name="RESTAPISample").order_by("name") self.assertEqual(2, matching_workbooks.total_available) - self.assertEqual('RESTAPISample', matching_workbooks[0].name) - self.assertEqual('RESTAPISample', matching_workbooks[1].name) + self.assertEqual("RESTAPISample", matching_workbooks[0].name) + self.assertEqual("RESTAPISample", matching_workbooks[1].name) def test_filter_tags_in(self): - with open(FILTER_TAGS_IN, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(FILTER_TAGS_IN, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/workbooks?filter=tags:in:[sample,safari,weather]', text=response_xml) + m.get(self.baseurl + "/workbooks?filter=tags:in:[sample,safari,weather]", text=response_xml) req_option = TSC.RequestOptions() - req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.In, - ['sample', 'safari', 'weather'])) + req_option.filter.add( + TSC.Filter( + TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.In, ["sample", "safari", "weather"] + ) + ) matching_workbooks, pagination_item = self.server.workbooks.get(req_option) self.assertEqual(3, pagination_item.total_available) - self.assertEqual(set(['weather']), matching_workbooks[0].tags) - self.assertEqual(set(['safari']), matching_workbooks[1].tags) - self.assertEqual(set(['sample']), matching_workbooks[2].tags) + self.assertEqual(set(["weather"]), matching_workbooks[0].tags) + self.assertEqual(set(["safari"]), matching_workbooks[1].tags) + self.assertEqual(set(["sample"]), matching_workbooks[2].tags) def test_filter_tags_in_shorthand(self): - with open(FILTER_TAGS_IN, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(FILTER_TAGS_IN, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/workbooks?filter=tags:in:[sample,safari,weather]', text=response_xml) - matching_workbooks = self.server.workbooks.filter(tags__in=['sample', 'safari', 'weather']) + m.get(self.baseurl + "/workbooks?filter=tags:in:[sample,safari,weather]", text=response_xml) + matching_workbooks = self.server.workbooks.filter(tags__in=["sample", "safari", "weather"]) self.assertEqual(3, matching_workbooks.total_available) - self.assertEqual(set(['weather']), matching_workbooks[0].tags) - self.assertEqual(set(['safari']), matching_workbooks[1].tags) - self.assertEqual(set(['sample']), matching_workbooks[2].tags) + self.assertEqual(set(["weather"]), matching_workbooks[0].tags) + self.assertEqual(set(["safari"]), matching_workbooks[1].tags) + self.assertEqual(set(["sample"]), matching_workbooks[2].tags) def test_invalid_shorthand_option(self): with self.assertRaises(ValueError): - self.server.workbooks.filter(nonexistant__in=['sample', 'safari']) + self.server.workbooks.filter(nonexistant__in=["sample", "safari"]) def test_multiple_filter_options(self): - with open(FILTER_MULTIPLE, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(FILTER_MULTIPLE, "rb") as f: + response_xml = f.read().decode("utf-8") # To ensure that this is deterministic, run this a few times with requests_mock.mock() as m: # Sometimes pep8 requires you to do things you might not otherwise do - url = ''.join((self.baseurl, '/workbooks?pageNumber=1&pageSize=100&', - 'filter=name:eq:foo,tags:in:[sample,safari,weather]')) + url = "".join( + ( + self.baseurl, + "/workbooks?pageNumber=1&pageSize=100&", + "filter=name:eq:foo,tags:in:[sample,safari,weather]", + ) + ) m.get(url, text=response_xml) req_option = TSC.RequestOptions() - req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.In, - ['sample', 'safari', 'weather'])) - req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, 'foo')) + req_option.filter.add( + TSC.Filter( + TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.In, ["sample", "safari", "weather"] + ) + ) + req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, "foo")) for _ in range(100): matching_workbooks, pagination_item = self.server.workbooks.get(req_option) self.assertEqual(3, pagination_item.total_available) @@ -145,16 +159,15 @@ def test_double_query_params(self): url = self.baseurl + "/views?queryParamExists=true" opts = TSC.RequestOptions() - opts.filter.add(TSC.Filter(TSC.RequestOptions.Field.Tags, - TSC.RequestOptions.Operator.In, - ['stocks', 'market'])) - opts.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Direction.Asc)) + opts.filter.add( + TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.In, ["stocks", "market"]) + ) + opts.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Direction.Asc)) resp = self.server.workbooks.get_request(url, request_object=opts) - self.assertTrue(re.search('queryparamexists=true', resp.request.query)) - self.assertTrue(re.search('filter=tags%3ain%3a%5bstocks%2cmarket%5d', resp.request.query)) - self.assertTrue(re.search('sort=name%3aasc', resp.request.query)) + self.assertTrue(re.search("queryparamexists=true", resp.request.query)) + self.assertTrue(re.search("filter=tags%3ain%3a%5bstocks%2cmarket%5d", resp.request.query)) + self.assertTrue(re.search("sort=name%3aasc", resp.request.query)) # Test req_options for versions below 3.7 def test_filter_sort_legacy(self): @@ -164,16 +177,15 @@ def test_filter_sort_legacy(self): url = self.baseurl + "/views?queryParamExists=true" opts = TSC.RequestOptions() - opts.filter.add(TSC.Filter(TSC.RequestOptions.Field.Tags, - TSC.RequestOptions.Operator.In, - ['stocks', 'market'])) - opts.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Direction.Asc)) + opts.filter.add( + TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.In, ["stocks", "market"]) + ) + opts.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Direction.Asc)) resp = self.server.workbooks.get_request(url, request_object=opts) - self.assertTrue(re.search('queryparamexists=true', resp.request.query)) - self.assertTrue(re.search('filter=tags:in:%5bstocks,market%5d', resp.request.query)) - self.assertTrue(re.search('sort=name:asc', resp.request.query)) + self.assertTrue(re.search("queryparamexists=true", resp.request.query)) + self.assertTrue(re.search("filter=tags:in:%5bstocks,market%5d", resp.request.query)) + self.assertTrue(re.search("sort=name:asc", resp.request.query)) def test_vf(self): with requests_mock.mock() as m: @@ -185,9 +197,9 @@ def test_vf(self): opts.page_type = TSC.PDFRequestOptions.PageType.Tabloid resp = self.server.workbooks.get_request(url, request_object=opts) - self.assertTrue(re.search('vf_name1%23=value1', resp.request.query)) - self.assertTrue(re.search('vf_name2%24=value2', resp.request.query)) - self.assertTrue(re.search('type=tabloid', resp.request.query)) + self.assertTrue(re.search("vf_name1%23=value1", resp.request.query)) + self.assertTrue(re.search("vf_name2%24=value2", resp.request.query)) + self.assertTrue(re.search("type=tabloid", resp.request.query)) # Test req_options for versions beloe 3.7 def test_vf_legacy(self): @@ -201,9 +213,9 @@ def test_vf_legacy(self): opts.page_type = TSC.PDFRequestOptions.PageType.Tabloid resp = self.server.workbooks.get_request(url, request_object=opts) - self.assertTrue(re.search('vf_name1@=value1', resp.request.query)) - self.assertTrue(re.search('vf_name2\\$=value2', resp.request.query)) - self.assertTrue(re.search('type=tabloid', resp.request.query)) + self.assertTrue(re.search("vf_name1@=value1", resp.request.query)) + self.assertTrue(re.search("vf_name2\\$=value2", resp.request.query)) + self.assertTrue(re.search("type=tabloid", resp.request.query)) def test_all_fields(self): with requests_mock.mock() as m: @@ -213,20 +225,51 @@ def test_all_fields(self): opts._all_fields = True resp = self.server.users.get_request(url, request_object=opts) - self.assertTrue(re.search('fields=_all_', resp.request.query)) + self.assertTrue(re.search("fields=_all_", resp.request.query)) def test_multiple_filter_options_shorthand(self): - with open(FILTER_MULTIPLE, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(FILTER_MULTIPLE, "rb") as f: + response_xml = f.read().decode("utf-8") # To ensure that this is deterministic, run this a few times with requests_mock.mock() as m: # Sometimes pep8 requires you to do things you might not otherwise do - url = ''.join((self.baseurl, '/workbooks?pageNumber=1&pageSize=100&', - 'filter=name:eq:foo,tags:in:[sample,safari,weather]')) + url = "".join( + ( + self.baseurl, + "/workbooks?pageNumber=1&pageSize=100&", + "filter=name:eq:foo,tags:in:[sample,safari,weather]", + ) + ) m.get(url, text=response_xml) for _ in range(100): - matching_workbooks = self.server.workbooks.filter( - tags__in=['sample', 'safari', 'weather'], name='foo' - ) + matching_workbooks = self.server.workbooks.filter(tags__in=["sample", "safari", "weather"], name="foo") self.assertEqual(3, matching_workbooks.total_available) + + def test_slicing_queryset(self): + with open(SLICING_QUERYSET, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get(self.baseurl + "/views?pageNumber=1", text=response_xml) + all_views = self.server.views.all() + + self.assertEqual(10, len(all_views[::])) + self.assertEqual(5, len(all_views[::2])) + self.assertEqual(8, len(all_views[2:])) + self.assertEqual(2, len(all_views[:2])) + self.assertEqual(3, len(all_views[2:5])) + self.assertEqual(3, len(all_views[-3:])) + self.assertEqual(3, len(all_views[-6:-3])) + self.assertEqual(3, len(all_views[3:6:-1])) + self.assertEqual(3, len(all_views[6:3:-1])) + self.assertEqual(10, len(all_views[::-1])) + self.assertEqual(all_views[3:6], list(reversed(all_views[3:6:-1]))) + + self.assertEqual(all_views[-3].id, "2df55de2-3a2d-4e34-b515-6d4e70b830e9") + + with self.assertRaises(IndexError): + all_views[100] + + def test_queryset_filter_args_error(self): + with self.assertRaises(RuntimeError): + workbooks = self.server.workbooks.filter("argument") diff --git a/test/test_requests.py b/test/test_requests.py index 2976e8f3e..82859dd26 100644 --- a/test/test_requests.py +++ b/test/test_requests.py @@ -1,20 +1,20 @@ -import unittest import re +import unittest + import requests import requests_mock import tableauserverclient as TSC - from tableauserverclient.server.endpoint.exceptions import InternalServerError, NonXMLResponseError class RequestTests(unittest.TestCase): def setUp(self): - self.server = TSC.Server('http://test') + 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.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.baseurl = self.server.workbooks.baseurl @@ -25,26 +25,30 @@ def test_make_get_request(self): opts = TSC.RequestOptions(pagesize=13, pagenumber=15) resp = self.server.workbooks.get_request(url, request_object=opts) - self.assertTrue(re.search('pagesize=13', resp.request.query)) - self.assertTrue(re.search('pagenumber=15', resp.request.query)) + self.assertTrue(re.search("pagesize=13", resp.request.query)) + self.assertTrue(re.search("pagenumber=15", resp.request.query)) def test_make_post_request(self): with requests_mock.mock() as m: m.post(requests_mock.ANY) url = "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks" - resp = self.server.workbooks._make_request(requests.post, url, content=b'1337', - auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', - content_type='multipart/mixed') - self.assertEqual(resp.request.headers['x-tableau-auth'], 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM') - self.assertEqual(resp.request.headers['content-type'], 'multipart/mixed') - self.assertEqual(resp.request.body, b'1337') + resp = self.server.workbooks._make_request( + requests.post, + url, + content=b"1337", + auth_token="j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM", + content_type="multipart/mixed", + ) + self.assertEqual(resp.request.headers["x-tableau-auth"], "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM") + self.assertEqual(resp.request.headers["content-type"], "multipart/mixed") + self.assertEqual(resp.request.body, b"1337") # Test that 500 server errors are handled properly def test_internal_server_error(self): self.server.version = "3.2" server_response = "500: Internal Server Error" with requests_mock.mock() as m: - m.register_uri('GET', self.server.server_info.baseurl, status_code=500, text=server_response) + m.register_uri("GET", self.server.server_info.baseurl, status_code=500, text=server_response) self.assertRaisesRegex(InternalServerError, server_response, self.server.server_info.get) # Test that non-xml server errors are handled properly @@ -52,5 +56,5 @@ def test_non_xml_error(self): self.server.version = "3.2" server_response = "this is not xml" with requests_mock.mock() as m: - m.register_uri('GET', self.server.server_info.baseurl, status_code=499, text=server_response) + m.register_uri("GET", self.server.server_info.baseurl, status_code=499, text=server_response) self.assertRaisesRegex(NonXMLResponseError, server_response, self.server.server_info.get) diff --git a/test/test_schedule.py b/test/test_schedule.py index 3a84caeb9..807467918 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -1,13 +1,16 @@ -from datetime import time -import unittest import os +import unittest +from datetime import time + 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") GET_XML = os.path.join(TEST_ASSET_DIR, "schedule_get.xml") +GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_by_id.xml") GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_empty.xml") CREATE_HOURLY_XML = os.path.join(TEST_ASSET_DIR, "schedule_create_hourly.xml") CREATE_DAILY_XML = os.path.join(TEST_ASSET_DIR, "schedule_create_daily.xml") @@ -17,14 +20,16 @@ ADD_WORKBOOK_TO_SCHEDULE = os.path.join(TEST_ASSET_DIR, "schedule_add_workbook.xml") ADD_WORKBOOK_TO_SCHEDULE_WITH_WARNINGS = os.path.join(TEST_ASSET_DIR, "schedule_add_workbook_with_warnings.xml") ADD_DATASOURCE_TO_SCHEDULE = os.path.join(TEST_ASSET_DIR, "schedule_add_datasource.xml") +ADD_FLOW_TO_SCHEDULE = os.path.join(TEST_ASSET_DIR, "schedule_add_flow.xml") -WORKBOOK_GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, 'workbook_get_by_id.xml') -DATASOURCE_GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, 'datasource_get_by_id.xml') +WORKBOOK_GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_by_id.xml") +DATASOURCE_GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "datasource_get_by_id.xml") +FLOW_GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "flow_get_by_id.xml") class ScheduleTests(unittest.TestCase): - def setUp(self): - self.server = TSC.Server("http://test") + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) # Fake Signin self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" @@ -32,7 +37,7 @@ def setUp(self): self.baseurl = self.server.schedules.baseurl - def test_get(self): + def test_get(self) -> None: with open(GET_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: @@ -71,7 +76,7 @@ def test_get(self): self.assertEqual("Flow", flow.schedule_type) self.assertEqual("2019-03-01T09:00:00Z", format_datetime(flow.next_run_at)) - def test_get_empty(self): + def test_get_empty(self) -> None: with open(GET_EMPTY_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: @@ -81,21 +86,38 @@ def test_get_empty(self): self.assertEqual(0, pagination_item.total_available) self.assertEqual([], all_schedules) - def test_delete(self): + def test_get_by_id(self) -> None: + self.server.version = "3.8" + with open(GET_BY_ID_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" + baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + m.get(baseurl, text=response_xml) + schedule = self.server.schedules.get_by_id(schedule_id) + self.assertIsNotNone(schedule) + self.assertEqual(schedule_id, schedule.id) + self.assertEqual("Weekday early mornings", schedule.name) + self.assertEqual("Active", schedule.state) + + def test_delete(self) -> None: with requests_mock.mock() as m: m.delete(self.baseurl + "/c9cff7f9-309c-4361-99ff-d4ba8c9f5467", status_code=204) self.server.schedules.delete("c9cff7f9-309c-4361-99ff-d4ba8c9f5467") - def test_create_hourly(self): + def test_create_hourly(self) -> None: with open(CREATE_HOURLY_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=response_xml) - hourly_interval = TSC.HourlyInterval(start_time=time(2, 30), - end_time=time(23, 0), - interval_value=2) - new_schedule = TSC.ScheduleItem("hourly-schedule-1", 50, TSC.ScheduleItem.Type.Extract, - TSC.ScheduleItem.ExecutionOrder.Parallel, hourly_interval) + hourly_interval = TSC.HourlyInterval(start_time=time(2, 30), end_time=time(23, 0), interval_value=2) + new_schedule = TSC.ScheduleItem( + "hourly-schedule-1", + 50, + TSC.ScheduleItem.Type.Extract, + TSC.ScheduleItem.ExecutionOrder.Parallel, + hourly_interval, + ) new_schedule = self.server.schedules.create(new_schedule) self.assertEqual("5f42be25-8a43-47ba-971a-63f2d4e7029c", new_schedule.id) @@ -108,17 +130,22 @@ def test_create_hourly(self): self.assertEqual("2016-09-16T01:30:00Z", format_datetime(new_schedule.next_run_at)) self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Parallel, new_schedule.execution_order) self.assertEqual(time(2, 30), new_schedule.interval_item.start_time) - self.assertEqual(time(23), new_schedule.interval_item.end_time) - self.assertEqual("8", new_schedule.interval_item.interval) + self.assertEqual(time(23), new_schedule.interval_item.end_time) # type: ignore[union-attr] + self.assertEqual("8", new_schedule.interval_item.interval) # type: ignore[union-attr] - def test_create_daily(self): + def test_create_daily(self) -> None: with open(CREATE_DAILY_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=response_xml) daily_interval = TSC.DailyInterval(time(4, 50)) - new_schedule = TSC.ScheduleItem("daily-schedule-1", 90, TSC.ScheduleItem.Type.Subscription, - TSC.ScheduleItem.ExecutionOrder.Serial, daily_interval) + new_schedule = TSC.ScheduleItem( + "daily-schedule-1", + 90, + TSC.ScheduleItem.Type.Subscription, + TSC.ScheduleItem.ExecutionOrder.Serial, + daily_interval, + ) new_schedule = self.server.schedules.create(new_schedule) self.assertEqual("907cae38-72fd-417c-892a-95540c4664cd", new_schedule.id) @@ -132,16 +159,21 @@ def test_create_daily(self): self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Serial, new_schedule.execution_order) self.assertEqual(time(4, 45), new_schedule.interval_item.start_time) - def test_create_weekly(self): + def test_create_weekly(self) -> None: with open(CREATE_WEEKLY_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=response_xml) - weekly_interval = TSC.WeeklyInterval(time(9, 15), TSC.IntervalItem.Day.Monday, - TSC.IntervalItem.Day.Wednesday, - TSC.IntervalItem.Day.Friday) - new_schedule = TSC.ScheduleItem("weekly-schedule-1", 80, TSC.ScheduleItem.Type.Extract, - TSC.ScheduleItem.ExecutionOrder.Parallel, weekly_interval) + weekly_interval = TSC.WeeklyInterval( + time(9, 15), TSC.IntervalItem.Day.Monday, TSC.IntervalItem.Day.Wednesday, TSC.IntervalItem.Day.Friday + ) + new_schedule = TSC.ScheduleItem( + "weekly-schedule-1", + 80, + TSC.ScheduleItem.Type.Extract, + TSC.ScheduleItem.ExecutionOrder.Parallel, + weekly_interval, + ) new_schedule = self.server.schedules.create(new_schedule) self.assertEqual("1adff386-6be0-4958-9f81-a35e676932bf", new_schedule.id) @@ -154,20 +186,24 @@ def test_create_weekly(self): self.assertEqual("2016-09-16T16:15:00Z", format_datetime(new_schedule.next_run_at)) self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Parallel, new_schedule.execution_order) self.assertEqual(time(9, 15), new_schedule.interval_item.start_time) - self.assertEqual(("Monday", "Wednesday", "Friday"), - new_schedule.interval_item.interval) + self.assertEqual(("Monday", "Wednesday", "Friday"), new_schedule.interval_item.interval) self.assertEqual(2, len(new_schedule.warnings)) self.assertEqual("warning 1", new_schedule.warnings[0]) self.assertEqual("warning 2", new_schedule.warnings[1]) - def test_create_monthly(self): + def test_create_monthly(self) -> None: with open(CREATE_MONTHLY_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=response_xml) monthly_interval = TSC.MonthlyInterval(time(7), 12) - new_schedule = TSC.ScheduleItem("monthly-schedule-1", 20, TSC.ScheduleItem.Type.Extract, - TSC.ScheduleItem.ExecutionOrder.Serial, monthly_interval) + new_schedule = TSC.ScheduleItem( + "monthly-schedule-1", + 20, + TSC.ScheduleItem.Type.Extract, + TSC.ScheduleItem.ExecutionOrder.Serial, + monthly_interval, + ) new_schedule = self.server.schedules.create(new_schedule) self.assertEqual("e06a7c75-5576-4f68-882d-8909d0219326", new_schedule.id) @@ -180,17 +216,21 @@ def test_create_monthly(self): self.assertEqual("2016-10-12T14:00:00Z", format_datetime(new_schedule.next_run_at)) self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Serial, new_schedule.execution_order) self.assertEqual(time(7), new_schedule.interval_item.start_time) - self.assertEqual("12", new_schedule.interval_item.interval) + self.assertEqual("12", new_schedule.interval_item.interval) # type: ignore[union-attr] - def test_update(self): + def test_update(self) -> None: with open(UPDATE_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.put(self.baseurl + '/7bea1766-1543-4052-9753-9d224bc069b5', text=response_xml) - new_interval = TSC.WeeklyInterval(time(7), TSC.IntervalItem.Day.Monday, - TSC.IntervalItem.Day.Friday) - single_schedule = TSC.ScheduleItem("weekly-schedule-1", 90, TSC.ScheduleItem.Type.Extract, - TSC.ScheduleItem.ExecutionOrder.Parallel, new_interval) + m.put(self.baseurl + "/7bea1766-1543-4052-9753-9d224bc069b5", text=response_xml) + new_interval = TSC.WeeklyInterval(time(7), TSC.IntervalItem.Day.Monday, TSC.IntervalItem.Day.Friday) + single_schedule = TSC.ScheduleItem( + "weekly-schedule-1", + 90, + TSC.ScheduleItem.Type.Extract, + TSC.ScheduleItem.ExecutionOrder.Parallel, + new_interval, + ) single_schedule._id = "7bea1766-1543-4052-9753-9d224bc069b5" single_schedule.state = TSC.ScheduleItem.State.Suspended single_schedule = self.server.schedules.update(single_schedule) @@ -203,12 +243,11 @@ def test_update(self): self.assertEqual("2016-09-16T14:00:00Z", format_datetime(single_schedule.next_run_at)) self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Parallel, single_schedule.execution_order) self.assertEqual(time(7), single_schedule.interval_item.start_time) - self.assertEqual(("Monday", "Friday"), - single_schedule.interval_item.interval) + self.assertEqual(("Monday", "Friday"), single_schedule.interval_item.interval) # type: ignore[union-attr] self.assertEqual(TSC.ScheduleItem.State.Suspended, single_schedule.state) # Tests calling update with a schedule item returned from the server - def test_update_after_get(self): + def test_update_after_get(self) -> None: with open(GET_XML, "rb") as f: get_response_xml = f.read().decode("utf-8") with open(UPDATE_XML, "rb") as f: @@ -220,19 +259,19 @@ def test_update_after_get(self): all_schedules, pagination_item = self.server.schedules.get() schedule_item = all_schedules[0] self.assertEqual(TSC.ScheduleItem.State.Active, schedule_item.state) - self.assertEqual('Weekday early mornings', schedule_item.name) + self.assertEqual("Weekday early mornings", schedule_item.name) # Update the schedule with requests_mock.mock() as m: - m.put(self.baseurl + '/c9cff7f9-309c-4361-99ff-d4ba8c9f5467', text=update_response_xml) + m.put(self.baseurl + "/c9cff7f9-309c-4361-99ff-d4ba8c9f5467", text=update_response_xml) schedule_item.state = TSC.ScheduleItem.State.Suspended - schedule_item.name = 'newName' + schedule_item.name = "newName" schedule_item = self.server.schedules.update(schedule_item) self.assertEqual(TSC.ScheduleItem.State.Suspended, schedule_item.state) - self.assertEqual('weekly-schedule-1', schedule_item.name) + self.assertEqual("weekly-schedule-1", schedule_item.name) - def test_add_workbook(self): + def test_add_workbook(self) -> None: self.server.version = "2.8" baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) @@ -241,13 +280,13 @@ def test_add_workbook(self): with open(ADD_WORKBOOK_TO_SCHEDULE, "rb") as f: add_workbook_response = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.server.workbooks.baseurl + '/bar', text=workbook_response) - m.put(baseurl + '/foo/workbooks', text=add_workbook_response) + m.get(self.server.workbooks.baseurl + "/bar", text=workbook_response) + m.put(baseurl + "/foo/workbooks", text=add_workbook_response) workbook = self.server.workbooks.get_by_id("bar") - result = self.server.schedules.add_to_schedule('foo', workbook=workbook) + result = self.server.schedules.add_to_schedule("foo", workbook=workbook) self.assertEqual(0, len(result), "Added properly") - def test_add_workbook_with_warnings(self): + def test_add_workbook_with_warnings(self) -> None: self.server.version = "2.8" baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) @@ -256,14 +295,14 @@ def test_add_workbook_with_warnings(self): with open(ADD_WORKBOOK_TO_SCHEDULE_WITH_WARNINGS, "rb") as f: add_workbook_response = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.server.workbooks.baseurl + '/bar', text=workbook_response) - m.put(baseurl + '/foo/workbooks', text=add_workbook_response) + m.get(self.server.workbooks.baseurl + "/bar", text=workbook_response) + m.put(baseurl + "/foo/workbooks", text=add_workbook_response) workbook = self.server.workbooks.get_by_id("bar") - result = self.server.schedules.add_to_schedule('foo', workbook=workbook) + result = self.server.schedules.add_to_schedule("foo", workbook=workbook) self.assertEqual(1, len(result), "Not added properly") self.assertEqual(2, len(result[0].warnings)) - def test_add_datasource(self): + def test_add_datasource(self) -> None: self.server.version = "2.8" baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) @@ -272,8 +311,23 @@ def test_add_datasource(self): with open(ADD_DATASOURCE_TO_SCHEDULE, "rb") as f: add_datasource_response = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.server.datasources.baseurl + '/bar', text=datasource_response) - m.put(baseurl + '/foo/datasources', text=add_datasource_response) + m.get(self.server.datasources.baseurl + "/bar", text=datasource_response) + m.put(baseurl + "/foo/datasources", text=add_datasource_response) datasource = self.server.datasources.get_by_id("bar") - result = self.server.schedules.add_to_schedule('foo', datasource=datasource) + result = self.server.schedules.add_to_schedule("foo", datasource=datasource) + self.assertEqual(0, len(result), "Added properly") + + def test_add_flow(self) -> None: + self.server.version = "3.3" + baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) + + with open(FLOW_GET_BY_ID_XML, "rb") as f: + flow_response = f.read().decode("utf-8") + with open(ADD_FLOW_TO_SCHEDULE, "rb") as f: + add_flow_response = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get(self.server.flows.baseurl + "/bar", text=flow_response) + m.put(baseurl + "/foo/flows", text=flow_response) + flow = self.server.flows.get_by_id("bar") + result = self.server.schedules.add_to_schedule("foo", flow=flow) self.assertEqual(0, len(result), "Added properly") diff --git a/test/test_server_info.py b/test/test_server_info.py index 3dadff7c1..80b071e75 100644 --- a/test/test_server_info.py +++ b/test/test_server_info.py @@ -1,62 +1,64 @@ -import unittest import os.path +import unittest + import requests_mock + import tableauserverclient as TSC -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") -SERVER_INFO_GET_XML = os.path.join(TEST_ASSET_DIR, 'server_info_get.xml') -SERVER_INFO_25_XML = os.path.join(TEST_ASSET_DIR, 'server_info_25.xml') -SERVER_INFO_404 = os.path.join(TEST_ASSET_DIR, 'server_info_404.xml') -SERVER_INFO_AUTH_INFO_XML = os.path.join(TEST_ASSET_DIR, 'server_info_auth_info.xml') +SERVER_INFO_GET_XML = os.path.join(TEST_ASSET_DIR, "server_info_get.xml") +SERVER_INFO_25_XML = os.path.join(TEST_ASSET_DIR, "server_info_25.xml") +SERVER_INFO_404 = os.path.join(TEST_ASSET_DIR, "server_info_404.xml") +SERVER_INFO_AUTH_INFO_XML = os.path.join(TEST_ASSET_DIR, "server_info_auth_info.xml") class ServerInfoTests(unittest.TestCase): def setUp(self): - self.server = TSC.Server('http://test') + self.server = TSC.Server("http://test", False) self.baseurl = self.server.server_info.baseurl self.server.version = "2.4" def test_server_info_get(self): - with open(SERVER_INFO_GET_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(SERVER_INFO_GET_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.get(self.server.server_info.baseurl, text=response_xml) actual = self.server.server_info.get() - self.assertEqual('10.1.0', actual.product_version) - self.assertEqual('10100.16.1024.2100', actual.build_number) - self.assertEqual('2.4', actual.rest_api_version) + self.assertEqual("10.1.0", actual.product_version) + self.assertEqual("10100.16.1024.2100", actual.build_number) + self.assertEqual("2.4", actual.rest_api_version) def test_server_info_use_highest_version_downgrades(self): - with open(SERVER_INFO_AUTH_INFO_XML, 'rb') as f: + with open(SERVER_INFO_AUTH_INFO_XML, "rb") as f: # This is the auth.xml endpoint present back to 9.0 Servers - auth_response_xml = f.read().decode('utf-8') - with open(SERVER_INFO_404, 'rb') as f: + auth_response_xml = f.read().decode("utf-8") + with open(SERVER_INFO_404, "rb") as f: # 10.1 serverInfo response - si_response_xml = f.read().decode('utf-8') + si_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: # Return a 404 for serverInfo so we can pretend this is an old Server m.get(self.server.server_address + "/api/2.4/serverInfo", text=si_response_xml, status_code=404) m.get(self.server.server_address + "/auth?format=xml", text=auth_response_xml) self.server.use_server_version() - self.assertEqual(self.server.version, '2.2') + self.assertEqual(self.server.version, "2.2") def test_server_info_use_highest_version_upgrades(self): - with open(SERVER_INFO_GET_XML, 'rb') as f: - si_response_xml = f.read().decode('utf-8') + with open(SERVER_INFO_GET_XML, "rb") as f: + si_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.get(self.server.server_address + "/api/2.4/serverInfo", text=si_response_xml) # Pretend we're old - self.server.version = '2.0' + self.server.version = "2.0" self.server.use_server_version() # Did we upgrade to 2.4? - self.assertEqual(self.server.version, '2.4') + self.assertEqual(self.server.version, "2.4") def test_server_use_server_version_flag(self): - with open(SERVER_INFO_25_XML, 'rb') as f: - si_response_xml = f.read().decode('utf-8') + with open(SERVER_INFO_25_XML, "rb") as f: + si_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get('http://test/api/2.4/serverInfo', text=si_response_xml) - server = TSC.Server('http://test', use_server_version=True) - self.assertEqual(server.version, '2.5') + m.get("http://test/api/2.4/serverInfo", text=si_response_xml) + server = TSC.Server("http://test", use_server_version=True) + self.assertEqual(server.version, "2.5") diff --git a/test/test_site.py b/test/test_site.py index 8fbb4eda3..23eb99ddd 100644 --- a/test/test_site.py +++ b/test/test_site.py @@ -1,39 +1,41 @@ -import unittest import os.path +import unittest + import requests_mock + import tableauserverclient as TSC -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") -GET_XML = os.path.join(TEST_ASSET_DIR, 'site_get.xml') -GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, 'site_get_by_id.xml') -GET_BY_NAME_XML = os.path.join(TEST_ASSET_DIR, 'site_get_by_name.xml') -UPDATE_XML = os.path.join(TEST_ASSET_DIR, 'site_update.xml') -CREATE_XML = os.path.join(TEST_ASSET_DIR, 'site_create.xml') +GET_XML = os.path.join(TEST_ASSET_DIR, "site_get.xml") +GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "site_get_by_id.xml") +GET_BY_NAME_XML = os.path.join(TEST_ASSET_DIR, "site_get_by_name.xml") +UPDATE_XML = os.path.join(TEST_ASSET_DIR, "site_update.xml") +CREATE_XML = os.path.join(TEST_ASSET_DIR, "site_create.xml") class SiteTests(unittest.TestCase): - def setUp(self): - self.server = TSC.Server('http://test') + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) self.server.version = "3.10" # Fake signin - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' - self.server._site_id = '0626857c-1def-4503-a7d8-7907c3ff9d9f' + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + self.server._site_id = "0626857c-1def-4503-a7d8-7907c3ff9d9f" self.baseurl = self.server.sites.baseurl - def test_get(self): - with open(GET_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_get(self) -> None: + with open(GET_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) all_sites, pagination_item = self.server.sites.get() self.assertEqual(2, pagination_item.total_available) - self.assertEqual('dad65087-b08b-4603-af4e-2887b8aafc67', all_sites[0].id) - self.assertEqual('Active', all_sites[0].state) - self.assertEqual('Default', all_sites[0].name) - self.assertEqual('ContentOnly', all_sites[0].admin_mode) + self.assertEqual("dad65087-b08b-4603-af4e-2887b8aafc67", all_sites[0].id) + self.assertEqual("Active", all_sites[0].state) + self.assertEqual("Default", all_sites[0].name) + self.assertEqual("ContentOnly", all_sites[0].admin_mode) self.assertEqual(False, all_sites[0].revision_history_enabled) self.assertEqual(True, all_sites[0].subscribe_others_enabled) self.assertEqual(25, all_sites[0].revision_limit) @@ -43,10 +45,10 @@ def test_get(self): self.assertEqual(False, all_sites[0].editing_flows_enabled) self.assertEqual(False, all_sites[0].scheduling_flows_enabled) self.assertEqual(True, all_sites[0].allow_subscription_attachments) - self.assertEqual('6b7179ba-b82b-4f0f-91ed-812074ac5da6', all_sites[1].id) - self.assertEqual('Active', all_sites[1].state) - self.assertEqual('Samples', all_sites[1].name) - self.assertEqual('ContentOnly', all_sites[1].admin_mode) + self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", all_sites[1].id) + self.assertEqual("Active", all_sites[1].state) + self.assertEqual("Samples", all_sites[1].name) + self.assertEqual("ContentOnly", all_sites[1].admin_mode) self.assertEqual(False, all_sites[1].revision_history_enabled) self.assertEqual(True, all_sites[1].subscribe_others_enabled) self.assertEqual(False, all_sites[1].guest_access_enabled) @@ -61,21 +63,21 @@ def test_get(self): self.assertEqual(False, all_sites[1].flows_enabled) self.assertEqual(None, all_sites[1].data_acceleration_mode) - def test_get_before_signin(self): + def test_get_before_signin(self) -> None: self.server._auth_token = None self.assertRaises(TSC.NotSignedInError, self.server.sites.get) - def test_get_by_id(self): - with open(GET_BY_ID_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_get_by_id(self) -> None: + with open(GET_BY_ID_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/dad65087-b08b-4603-af4e-2887b8aafc67', text=response_xml) - single_site = self.server.sites.get_by_id('dad65087-b08b-4603-af4e-2887b8aafc67') + m.get(self.baseurl + "/dad65087-b08b-4603-af4e-2887b8aafc67", text=response_xml) + single_site = self.server.sites.get_by_id("dad65087-b08b-4603-af4e-2887b8aafc67") - self.assertEqual('dad65087-b08b-4603-af4e-2887b8aafc67', single_site.id) - self.assertEqual('Active', single_site.state) - self.assertEqual('Default', single_site.name) - self.assertEqual('ContentOnly', single_site.admin_mode) + self.assertEqual("dad65087-b08b-4603-af4e-2887b8aafc67", single_site.id) + self.assertEqual("Active", single_site.state) + self.assertEqual("Default", single_site.name) + self.assertEqual("ContentOnly", single_site.admin_mode) self.assertEqual(False, single_site.revision_history_enabled) self.assertEqual(True, single_site.subscribe_others_enabled) self.assertEqual(False, single_site.disable_subscriptions) @@ -83,61 +85,80 @@ def test_get_by_id(self): self.assertEqual(False, single_site.commenting_mentions_enabled) self.assertEqual(True, single_site.catalog_obfuscation_enabled) - def test_get_by_id_missing_id(self): - self.assertRaises(ValueError, self.server.sites.get_by_id, '') + def test_get_by_id_missing_id(self) -> None: + self.assertRaises(ValueError, self.server.sites.get_by_id, "") - def test_get_by_name(self): - with open(GET_BY_NAME_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_get_by_name(self) -> None: + with open(GET_BY_NAME_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/testsite?key=name', text=response_xml) - single_site = self.server.sites.get_by_name('testsite') + m.get(self.baseurl + "/testsite?key=name", text=response_xml) + single_site = self.server.sites.get_by_name("testsite") - self.assertEqual('dad65087-b08b-4603-af4e-2887b8aafc67', single_site.id) - self.assertEqual('Active', single_site.state) - self.assertEqual('testsite', single_site.name) - self.assertEqual('ContentOnly', single_site.admin_mode) + self.assertEqual("dad65087-b08b-4603-af4e-2887b8aafc67", single_site.id) + self.assertEqual("Active", single_site.state) + self.assertEqual("testsite", single_site.name) + self.assertEqual("ContentOnly", single_site.admin_mode) self.assertEqual(False, single_site.revision_history_enabled) self.assertEqual(True, single_site.subscribe_others_enabled) self.assertEqual(False, single_site.disable_subscriptions) - def test_get_by_name_missing_name(self): - self.assertRaises(ValueError, self.server.sites.get_by_name, '') + def test_get_by_name_missing_name(self) -> None: + self.assertRaises(ValueError, self.server.sites.get_by_name, "") - def test_update(self): - with open(UPDATE_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_update(self) -> None: + with open(UPDATE_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.put(self.baseurl + '/6b7179ba-b82b-4f0f-91ed-812074ac5da6', text=response_xml) - single_site = TSC.SiteItem(name='Tableau', content_url='tableau', - admin_mode=TSC.SiteItem.AdminMode.ContentAndUsers, - user_quota=15, storage_quota=1000, - disable_subscriptions=True, revision_history_enabled=False, - data_acceleration_mode='disable', flow_auto_save_enabled=True, - web_extraction_enabled=False, metrics_content_type_enabled=True, - notify_site_admins_on_throttle=False, authoring_enabled=True, - custom_subscription_email_enabled=True, - custom_subscription_email='test@test.com', - custom_subscription_footer_enabled=True, - custom_subscription_footer='example_footer', ask_data_mode='EnabledByDefault', - named_sharing_enabled=False, mobile_biometrics_enabled=True, - sheet_image_enabled=False, derived_permissions_enabled=True, - user_visibility_mode='FULL', use_default_time_zone=False, - time_zone='America/Los_Angeles', auto_suspend_refresh_enabled=True, - auto_suspend_refresh_inactivity_window=55) - single_site._id = '6b7179ba-b82b-4f0f-91ed-812074ac5da6' + m.put(self.baseurl + "/6b7179ba-b82b-4f0f-91ed-812074ac5da6", text=response_xml) + single_site = TSC.SiteItem( + name="Tableau", + content_url="tableau", + admin_mode=TSC.SiteItem.AdminMode.ContentAndUsers, + user_quota=15, + storage_quota=1000, + disable_subscriptions=True, + revision_history_enabled=False, + data_acceleration_mode="disable", + flow_auto_save_enabled=True, + web_extraction_enabled=False, + metrics_content_type_enabled=True, + notify_site_admins_on_throttle=False, + authoring_enabled=True, + custom_subscription_email_enabled=True, + custom_subscription_email="test@test.com", + custom_subscription_footer_enabled=True, + custom_subscription_footer="example_footer", + ask_data_mode="EnabledByDefault", + named_sharing_enabled=False, + mobile_biometrics_enabled=True, + sheet_image_enabled=False, + derived_permissions_enabled=True, + user_visibility_mode="FULL", + use_default_time_zone=False, + time_zone="America/Los_Angeles", + auto_suspend_refresh_enabled=True, + auto_suspend_refresh_inactivity_window=55, + tier_creator_capacity=5, + tier_explorer_capacity=5, + tier_viewer_capacity=5, + ) + single_site._id = "6b7179ba-b82b-4f0f-91ed-812074ac5da6" single_site = self.server.sites.update(single_site) - self.assertEqual('6b7179ba-b82b-4f0f-91ed-812074ac5da6', single_site.id) - self.assertEqual('tableau', single_site.content_url) - self.assertEqual('Suspended', single_site.state) - self.assertEqual('Tableau', single_site.name) - self.assertEqual('ContentAndUsers', single_site.admin_mode) + self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", single_site.id) + self.assertEqual("tableau", single_site.content_url) + self.assertEqual("Suspended", single_site.state) + self.assertEqual("Tableau", single_site.name) + self.assertEqual("ContentAndUsers", single_site.admin_mode) self.assertEqual(True, single_site.revision_history_enabled) self.assertEqual(13, single_site.revision_limit) self.assertEqual(True, single_site.disable_subscriptions) - self.assertEqual(15, single_site.user_quota) - self.assertEqual('disable', single_site.data_acceleration_mode) + self.assertEqual(None, single_site.user_quota) + self.assertEqual(5, single_site.tier_creator_capacity) + self.assertEqual(5, single_site.tier_explorer_capacity) + self.assertEqual(5, single_site.tier_viewer_capacity) + self.assertEqual("disable", single_site.data_acceleration_mode) self.assertEqual(True, single_site.flows_enabled) self.assertEqual(True, single_site.cataloging_enabled) self.assertEqual(True, single_site.flow_auto_save_enabled) @@ -146,63 +167,88 @@ def test_update(self): self.assertEqual(False, single_site.notify_site_admins_on_throttle) self.assertEqual(True, single_site.authoring_enabled) self.assertEqual(True, single_site.custom_subscription_email_enabled) - self.assertEqual('test@test.com', single_site.custom_subscription_email) + self.assertEqual("test@test.com", single_site.custom_subscription_email) self.assertEqual(True, single_site.custom_subscription_footer_enabled) - self.assertEqual('example_footer', single_site.custom_subscription_footer) - self.assertEqual('EnabledByDefault', single_site.ask_data_mode) + self.assertEqual("example_footer", single_site.custom_subscription_footer) + self.assertEqual("EnabledByDefault", single_site.ask_data_mode) self.assertEqual(False, single_site.named_sharing_enabled) self.assertEqual(True, single_site.mobile_biometrics_enabled) self.assertEqual(False, single_site.sheet_image_enabled) self.assertEqual(True, single_site.derived_permissions_enabled) - self.assertEqual('FULL', single_site.user_visibility_mode) + self.assertEqual("FULL", single_site.user_visibility_mode) self.assertEqual(False, single_site.use_default_time_zone) - self.assertEqual('America/Los_Angeles', single_site.time_zone) + self.assertEqual("America/Los_Angeles", single_site.time_zone) self.assertEqual(True, single_site.auto_suspend_refresh_enabled) self.assertEqual(55, single_site.auto_suspend_refresh_inactivity_window) - def test_update_missing_id(self): - single_site = TSC.SiteItem('test', 'test') + def test_update_missing_id(self) -> None: + single_site = TSC.SiteItem("test", "test") self.assertRaises(TSC.MissingRequiredFieldError, self.server.sites.update, single_site) - def test_create(self): - with open(CREATE_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_null_site_quota(self) -> None: + test_site = TSC.SiteItem("testname", "testcontenturl", tier_explorer_capacity=1, user_quota=None) + assert test_site.tier_explorer_capacity == 1 + with self.assertRaises(ValueError): + test_site.user_quota = 1 + test_site.tier_explorer_capacity = None + test_site.user_quota = 1 + + def test_replace_license_tiers_with_user_quota(self) -> None: + test_site = TSC.SiteItem("testname", "testcontenturl", tier_explorer_capacity=1, user_quota=None) + assert test_site.tier_explorer_capacity == 1 + with self.assertRaises(ValueError): + test_site.user_quota = 1 + test_site.replace_license_tiers_with_user_quota(1) + self.assertEqual(1, test_site.user_quota) + self.assertIsNone(test_site.tier_explorer_capacity) + + def test_create(self) -> None: + with open(CREATE_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_site = TSC.SiteItem(name='Tableau', content_url='tableau', - admin_mode=TSC.SiteItem.AdminMode.ContentAndUsers, user_quota=15, - storage_quota=1000, disable_subscriptions=True) + new_site = TSC.SiteItem( + name="Tableau", + content_url="tableau", + admin_mode=TSC.SiteItem.AdminMode.ContentAndUsers, + user_quota=15, + storage_quota=1000, + disable_subscriptions=True, + ) new_site = self.server.sites.create(new_site) - self.assertEqual('0626857c-1def-4503-a7d8-7907c3ff9d9f', new_site.id) - self.assertEqual('tableau', new_site.content_url) - self.assertEqual('Tableau', new_site.name) - self.assertEqual('Active', new_site.state) - self.assertEqual('ContentAndUsers', new_site.admin_mode) + new_site._tier_viewer_capacity = None + new_site._tier_creator_capacity = None + new_site._tier_explorer_capacity = None + self.assertEqual("0626857c-1def-4503-a7d8-7907c3ff9d9f", new_site.id) + self.assertEqual("tableau", new_site.content_url) + self.assertEqual("Tableau", new_site.name) + self.assertEqual("Active", new_site.state) + self.assertEqual("ContentAndUsers", new_site.admin_mode) self.assertEqual(False, new_site.revision_history_enabled) self.assertEqual(True, new_site.subscribe_others_enabled) self.assertEqual(True, new_site.disable_subscriptions) self.assertEqual(15, new_site.user_quota) - def test_delete(self): + def test_delete(self) -> None: with requests_mock.mock() as m: - m.delete(self.baseurl + '/0626857c-1def-4503-a7d8-7907c3ff9d9f', status_code=204) - self.server.sites.delete('0626857c-1def-4503-a7d8-7907c3ff9d9f') + m.delete(self.baseurl + "/0626857c-1def-4503-a7d8-7907c3ff9d9f", status_code=204) + self.server.sites.delete("0626857c-1def-4503-a7d8-7907c3ff9d9f") - def test_delete_missing_id(self): - self.assertRaises(ValueError, self.server.sites.delete, '') + def test_delete_missing_id(self) -> None: + self.assertRaises(ValueError, self.server.sites.delete, "") - def test_encrypt(self): + def test_encrypt(self) -> None: with requests_mock.mock() as m: - m.post(self.baseurl + '/0626857c-1def-4503-a7d8-7907c3ff9d9f/encrypt-extracts', status_code=200) - self.server.sites.encrypt_extracts('0626857c-1def-4503-a7d8-7907c3ff9d9f') + m.post(self.baseurl + "/0626857c-1def-4503-a7d8-7907c3ff9d9f/encrypt-extracts", status_code=200) + self.server.sites.encrypt_extracts("0626857c-1def-4503-a7d8-7907c3ff9d9f") - def test_recrypt(self): + def test_recrypt(self) -> None: with requests_mock.mock() as m: - m.post(self.baseurl + '/0626857c-1def-4503-a7d8-7907c3ff9d9f/reencrypt-extracts', status_code=200) - self.server.sites.re_encrypt_extracts('0626857c-1def-4503-a7d8-7907c3ff9d9f') + m.post(self.baseurl + "/0626857c-1def-4503-a7d8-7907c3ff9d9f/reencrypt-extracts", status_code=200) + self.server.sites.re_encrypt_extracts("0626857c-1def-4503-a7d8-7907c3ff9d9f") - def test_decrypt(self): + def test_decrypt(self) -> None: with requests_mock.mock() as m: - m.post(self.baseurl + '/0626857c-1def-4503-a7d8-7907c3ff9d9f/decrypt-extracts', status_code=200) - self.server.sites.decrypt_extracts('0626857c-1def-4503-a7d8-7907c3ff9d9f') + m.post(self.baseurl + "/0626857c-1def-4503-a7d8-7907c3ff9d9f/decrypt-extracts", status_code=200) + self.server.sites.decrypt_extracts("0626857c-1def-4503-a7d8-7907c3ff9d9f") diff --git a/test/test_site_model.py b/test/test_site_model.py index 99fa73ce9..eb086f5af 100644 --- a/test/test_site_model.py +++ b/test/test_site_model.py @@ -1,6 +1,7 @@ # coding=utf-8 import unittest + import tableauserverclient as TSC diff --git a/test/test_sort.py b/test/test_sort.py index 0572a1e10..8eebef6f4 100644 --- a/test/test_sort.py +++ b/test/test_sort.py @@ -1,16 +1,17 @@ -import unittest import re -import requests +import unittest + import requests_mock + import tableauserverclient as TSC class SortTests(unittest.TestCase): def setUp(self): - self.server = TSC.Server('http://test') + self.server = TSC.Server("http://test", False) self.server.version = "3.10" - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.baseurl = self.server.workbooks.baseurl def test_empty_filter(self): @@ -21,21 +22,17 @@ def test_filter_equals(self): m.get(requests_mock.ANY) url = "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks" opts = TSC.RequestOptions(pagesize=13, pagenumber=13) - opts.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Operator.Equals, - 'Superstore')) + opts.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, "Superstore")) resp = self.server.workbooks.get_request(url, request_object=opts) - self.assertTrue(re.search('pagenumber=13', resp.request.query)) - self.assertTrue(re.search('pagesize=13', resp.request.query)) - self.assertTrue(re.search('filter=name%3aeq%3asuperstore', resp.request.query)) + self.assertTrue(re.search("pagenumber=13", resp.request.query)) + self.assertTrue(re.search("pagesize=13", resp.request.query)) + self.assertTrue(re.search("filter=name%3aeq%3asuperstore", resp.request.query)) def test_filter_equals_list(self): with self.assertRaises(ValueError) as cm: - TSC.Filter(TSC.RequestOptions.Field.Tags, - TSC.RequestOptions.Operator.Equals, - ['foo', 'bar']) + TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.Equals, ["foo", "bar"]) self.assertEqual("Filter values can only be a list if the operator is 'in'.", str(cm.exception)), @@ -45,28 +42,27 @@ def test_filter_in(self): url = "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks" opts = TSC.RequestOptions(pagesize=13, pagenumber=13) - opts.filter.add(TSC.Filter(TSC.RequestOptions.Field.Tags, - TSC.RequestOptions.Operator.In, - ['stocks', 'market'])) + opts.filter.add( + TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.In, ["stocks", "market"]) + ) resp = self.server.workbooks.get_request(url, request_object=opts) - self.assertTrue(re.search('pagenumber=13', resp.request.query)) - self.assertTrue(re.search('pagesize=13', resp.request.query)) - self.assertTrue(re.search('filter=tags%3ain%3a%5bstocks%2cmarket%5d', resp.request.query)) + self.assertTrue(re.search("pagenumber=13", resp.request.query)) + self.assertTrue(re.search("pagesize=13", resp.request.query)) + self.assertTrue(re.search("filter=tags%3ain%3a%5bstocks%2cmarket%5d", resp.request.query)) def test_sort_asc(self): with requests_mock.mock() as m: m.get(requests_mock.ANY) url = "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks" opts = TSC.RequestOptions(pagesize=13, pagenumber=13) - opts.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Direction.Asc)) + opts.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Direction.Asc)) resp = self.server.workbooks.get_request(url, request_object=opts) - self.assertTrue(re.search('pagenumber=13', resp.request.query)) - self.assertTrue(re.search('pagesize=13', resp.request.query)) - self.assertTrue(re.search('sort=name%3aasc', resp.request.query)) + self.assertTrue(re.search("pagenumber=13", resp.request.query)) + self.assertTrue(re.search("pagesize=13", resp.request.query)) + self.assertTrue(re.search("sort=name%3aasc", resp.request.query)) def test_filter_combo(self): with requests_mock.mock() as m: @@ -74,25 +70,34 @@ def test_filter_combo(self): url = "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/users" opts = TSC.RequestOptions(pagesize=13, pagenumber=13) - opts.filter.add(TSC.Filter(TSC.RequestOptions.Field.LastLogin, - TSC.RequestOptions.Operator.GreaterThanOrEqual, - '2017-01-15T00:00:00:00Z')) + opts.filter.add( + TSC.Filter( + TSC.RequestOptions.Field.LastLogin, + TSC.RequestOptions.Operator.GreaterThanOrEqual, + "2017-01-15T00:00:00:00Z", + ) + ) - opts.filter.add(TSC.Filter(TSC.RequestOptions.Field.SiteRole, - TSC.RequestOptions.Operator.Equals, - 'Publisher')) + opts.filter.add( + TSC.Filter(TSC.RequestOptions.Field.SiteRole, TSC.RequestOptions.Operator.Equals, "Publisher") + ) resp = self.server.workbooks.get_request(url, request_object=opts) - expected = 'pagenumber=13&pagesize=13&filter=lastlogin%3agte%3a' \ - '2017-01-15t00%3a00%3a00%3a00z%2csiterole%3aeq%3apublisher' + expected = ( + "pagenumber=13&pagesize=13&filter=lastlogin%3agte%3a" + "2017-01-15t00%3a00%3a00%3a00z%2csiterole%3aeq%3apublisher" + ) - self.assertTrue(re.search('pagenumber=13', resp.request.query)) - self.assertTrue(re.search('pagesize=13', resp.request.query)) - self.assertTrue(re.search( - 'filter=lastlogin%3agte%3a2017-01-15t00%3a00%3a00%3a00z%2csiterole%3aeq%3apublisher', - resp.request.query)) + self.assertTrue(re.search("pagenumber=13", resp.request.query)) + self.assertTrue(re.search("pagesize=13", resp.request.query)) + self.assertTrue( + re.search( + "filter=lastlogin%3agte%3a2017-01-15t00%3a00%3a00%3a00z%2csiterole%3aeq%3apublisher", + resp.request.query, + ) + ) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/test/test_subscription.py b/test/test_subscription.py index 15b845e56..45dcb0a1c 100644 --- a/test/test_subscription.py +++ b/test/test_subscription.py @@ -1,6 +1,8 @@ -import unittest import os +import unittest + import requests_mock + import tableauserverclient as TSC TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -11,9 +13,9 @@ class SubscriptionTests(unittest.TestCase): - def setUp(self): - self.server = TSC.Server("http://test") - self.server.version = '2.6' + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) + self.server.version = "2.6" # Fake Signin self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" @@ -21,7 +23,7 @@ def setUp(self): self.baseurl = self.server.subscriptions.baseurl - def test_get_subscriptions(self): + def test_get_subscriptions(self) -> None: with open(GET_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: @@ -30,58 +32,59 @@ def test_get_subscriptions(self): self.assertEqual(2, pagination_item.total_available) subscription = all_subscriptions[0] - self.assertEqual('382e9a6e-0c08-4a95-b6c1-c14df7bac3e4', subscription.id) - self.assertEqual('NOT FOUND!', subscription.message) + self.assertEqual("382e9a6e-0c08-4a95-b6c1-c14df7bac3e4", subscription.id) + self.assertEqual("NOT FOUND!", subscription.message) self.assertTrue(subscription.attach_image) self.assertFalse(subscription.attach_pdf) self.assertFalse(subscription.suspended) self.assertFalse(subscription.send_if_view_empty) self.assertIsNone(subscription.page_orientation) self.assertIsNone(subscription.page_size_option) - self.assertEqual('Not Found Alert', subscription.subject) - self.assertEqual('cdd716ca-5818-470e-8bec-086885dbadee', subscription.target.id) - self.assertEqual('View', subscription.target.type) - self.assertEqual('c0d5fc44-ad8c-4957-bec0-b70ed0f8df1e', subscription.user_id) - self.assertEqual('7617c389-cdca-4940-a66e-69956fcebf3e', subscription.schedule_id) + self.assertEqual("Not Found Alert", subscription.subject) + self.assertEqual("cdd716ca-5818-470e-8bec-086885dbadee", subscription.target.id) + self.assertEqual("View", subscription.target.type) + self.assertEqual("c0d5fc44-ad8c-4957-bec0-b70ed0f8df1e", subscription.user_id) + self.assertEqual("7617c389-cdca-4940-a66e-69956fcebf3e", subscription.schedule_id) subscription = all_subscriptions[1] - self.assertEqual('23cb7630-afc8-4c8e-b6cd-83ae0322ec66', subscription.id) - self.assertEqual('overview', subscription.message) + self.assertEqual("23cb7630-afc8-4c8e-b6cd-83ae0322ec66", subscription.id) + self.assertEqual("overview", subscription.message) self.assertFalse(subscription.attach_image) self.assertTrue(subscription.attach_pdf) self.assertTrue(subscription.suspended) self.assertTrue(subscription.send_if_view_empty) - self.assertEqual('PORTRAIT', subscription.page_orientation) - self.assertEqual('A5', subscription.page_size_option) - self.assertEqual('Last 7 Days', subscription.subject) - self.assertEqual('2e6b4e8f-22dd-4061-8f75-bf33703da7e5', subscription.target.id) - self.assertEqual('Workbook', subscription.target.type) - self.assertEqual('c0d5fc44-ad8c-4957-bec0-b70ed0f8df1e', subscription.user_id) - self.assertEqual('3407cd38-7b39-4983-86a6-67a1506a5e3f', subscription.schedule_id) - - def test_get_subscription_by_id(self): + self.assertEqual("PORTRAIT", subscription.page_orientation) + self.assertEqual("A5", subscription.page_size_option) + self.assertEqual("Last 7 Days", subscription.subject) + self.assertEqual("2e6b4e8f-22dd-4061-8f75-bf33703da7e5", subscription.target.id) + self.assertEqual("Workbook", subscription.target.type) + self.assertEqual("c0d5fc44-ad8c-4957-bec0-b70ed0f8df1e", subscription.user_id) + self.assertEqual("3407cd38-7b39-4983-86a6-67a1506a5e3f", subscription.schedule_id) + + def test_get_subscription_by_id(self) -> None: with open(GET_XML_BY_ID, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/382e9a6e-0c08-4a95-b6c1-c14df7bac3e4', text=response_xml) - subscription = self.server.subscriptions.get_by_id('382e9a6e-0c08-4a95-b6c1-c14df7bac3e4') - - self.assertEqual('382e9a6e-0c08-4a95-b6c1-c14df7bac3e4', subscription.id) - self.assertEqual('View', subscription.target.type) - self.assertEqual('cdd716ca-5818-470e-8bec-086885dbadee', subscription.target.id) - self.assertEqual('c0d5fc44-ad8c-4957-bec0-b70ed0f8df1e', subscription.user_id) - self.assertEqual('Not Found Alert', subscription.subject) - self.assertEqual('7617c389-cdca-4940-a66e-69956fcebf3e', subscription.schedule_id) - - def test_create_subscription(self): - with open(CREATE_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + m.get(self.baseurl + "/382e9a6e-0c08-4a95-b6c1-c14df7bac3e4", text=response_xml) + subscription = self.server.subscriptions.get_by_id("382e9a6e-0c08-4a95-b6c1-c14df7bac3e4") + + self.assertEqual("382e9a6e-0c08-4a95-b6c1-c14df7bac3e4", subscription.id) + self.assertEqual("View", subscription.target.type) + self.assertEqual("cdd716ca-5818-470e-8bec-086885dbadee", subscription.target.id) + self.assertEqual("c0d5fc44-ad8c-4957-bec0-b70ed0f8df1e", subscription.user_id) + self.assertEqual("Not Found Alert", subscription.subject) + self.assertEqual("7617c389-cdca-4940-a66e-69956fcebf3e", subscription.schedule_id) + + def test_create_subscription(self) -> None: + with open(CREATE_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=response_xml) target_item = TSC.Target("960e61f2-1838-40b2-bba2-340c9492f943", "workbook") - new_subscription = TSC.SubscriptionItem("subject", "4906c453-d5ec-4972-9ff4-789b629bdfa2", - "8d30c8de-0a5f-4bee-b266-c621b4f3eed0", target_item) + new_subscription = TSC.SubscriptionItem( + "subject", "4906c453-d5ec-4972-9ff4-789b629bdfa2", "8d30c8de-0a5f-4bee-b266-c621b4f3eed0", target_item + ) new_subscription = self.server.subscriptions.create(new_subscription) self.assertEqual("78e9318d-2d29-4d67-b60f-3f2f5fd89ecc", new_subscription.id) @@ -91,7 +94,7 @@ def test_create_subscription(self): self.assertEqual("4906c453-d5ec-4972-9ff4-789b629bdfa2", new_subscription.schedule_id) self.assertEqual("8d30c8de-0a5f-4bee-b266-c621b4f3eed0", new_subscription.user_id) - def test_delete_subscription(self): + def test_delete_subscription(self) -> None: with requests_mock.mock() as m: - m.delete(self.baseurl + '/78e9318d-2d29-4d67-b60f-3f2f5fd89ecc', status_code=204) - self.server.subscriptions.delete('78e9318d-2d29-4d67-b60f-3f2f5fd89ecc') + m.delete(self.baseurl + "/78e9318d-2d29-4d67-b60f-3f2f5fd89ecc", status_code=204) + self.server.subscriptions.delete("78e9318d-2d29-4d67-b60f-3f2f5fd89ecc") diff --git a/test/test_table.py b/test/test_table.py index 45af43c9a..8c6c71f76 100644 --- a/test/test_table.py +++ b/test/test_table.py @@ -1,24 +1,21 @@ import unittest -import os + import requests_mock -import xml.etree.ElementTree as ET + import tableauserverclient as TSC -from tableauserverclient.datetime_helpers import format_datetime -from tableauserverclient.server.endpoint.exceptions import InternalServerError -from tableauserverclient.server.request_factory import RequestFactory -from ._utils import read_xml_asset, read_xml_assets, asset +from ._utils import read_xml_asset -GET_XML = 'table_get.xml' -UPDATE_XML = 'table_update.xml' +GET_XML = "table_get.xml" +UPDATE_XML = "table_update.xml" class TableTests(unittest.TestCase): def setUp(self): - self.server = TSC.Server('http://test') + self.server = TSC.Server("http://test", False) # Fake signin - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.server.version = "3.5" self.baseurl = self.server.tables.baseurl @@ -30,33 +27,33 @@ def test_get(self): all_tables, pagination_item = self.server.tables.get() self.assertEqual(4, pagination_item.total_available) - self.assertEqual('10224773-ecee-42ac-b822-d786b0b8e4d9', all_tables[0].id) - self.assertEqual('dim_Product', all_tables[0].name) + self.assertEqual("10224773-ecee-42ac-b822-d786b0b8e4d9", all_tables[0].id) + self.assertEqual("dim_Product", all_tables[0].name) - self.assertEqual('53c77bc1-fb41-4342-a75a-f68ac0656d0d', all_tables[1].id) - self.assertEqual('customer', all_tables[1].name) - self.assertEqual('dbo', all_tables[1].schema) - self.assertEqual('9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0', all_tables[1].contact_id) + self.assertEqual("53c77bc1-fb41-4342-a75a-f68ac0656d0d", all_tables[1].id) + self.assertEqual("customer", all_tables[1].name) + self.assertEqual("dbo", all_tables[1].schema) + self.assertEqual("9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0", all_tables[1].contact_id) self.assertEqual(False, all_tables[1].certified) def test_update(self): response_xml = read_xml_asset(UPDATE_XML) with requests_mock.mock() as m: - m.put(self.baseurl + '/10224773-ecee-42ac-b822-d786b0b8e4d9', text=response_xml) - single_table = TSC.TableItem('test') - single_table._id = '10224773-ecee-42ac-b822-d786b0b8e4d9' + m.put(self.baseurl + "/10224773-ecee-42ac-b822-d786b0b8e4d9", text=response_xml) + single_table = TSC.TableItem("test") + single_table._id = "10224773-ecee-42ac-b822-d786b0b8e4d9" - single_table.contact_id = '8e1a8235-c9ee-4d61-ae82-2ffacceed8e0' + single_table.contact_id = "8e1a8235-c9ee-4d61-ae82-2ffacceed8e0" single_table.certified = True single_table.certification_note = "Test" single_table = self.server.tables.update(single_table) - self.assertEqual('10224773-ecee-42ac-b822-d786b0b8e4d9', single_table.id) - self.assertEqual('8e1a8235-c9ee-4d61-ae82-2ffacceed8e0', single_table.contact_id) + self.assertEqual("10224773-ecee-42ac-b822-d786b0b8e4d9", single_table.id) + self.assertEqual("8e1a8235-c9ee-4d61-ae82-2ffacceed8e0", single_table.contact_id) self.assertEqual(True, single_table.certified) self.assertEqual("Test", single_table.certification_note) def test_delete(self): with requests_mock.mock() as m: - m.delete(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5', status_code=204) - self.server.tables.delete('0448d2ed-590d-4fa0-b272-a2a8a24555b5') + m.delete(self.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5", status_code=204) + self.server.tables.delete("0448d2ed-590d-4fa0-b272-a2a8a24555b5") diff --git a/test/test_tableauauth_model.py b/test/test_tableauauth_model.py index 94a44706a..e8ae242d9 100644 --- a/test/test_tableauauth_model.py +++ b/test/test_tableauauth_model.py @@ -1,14 +1,12 @@ import unittest import warnings + import tableauserverclient as TSC class TableauAuthModelTests(unittest.TestCase): def setUp(self): - self.auth = TSC.TableauAuth('user', - 'password', - site_id='site1', - user_id_to_impersonate='admin') + self.auth = TSC.TableauAuth("user", "password", site_id="site1", user_id_to_impersonate="admin") def test_username_password_required(self): with self.assertRaises(TypeError): @@ -18,8 +16,6 @@ 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') + tableau_auth = TSC.TableauAuth("user", "password", site="Default") self.assertTrue(any(item.category == DeprecationWarning for item in w)) diff --git a/test/test_task.py b/test/test_task.py index 566167d4a..5c432208d 100644 --- a/test/test_task.py +++ b/test/test_task.py @@ -1,9 +1,11 @@ -import unittest import os +import unittest + import requests_mock + import tableauserverclient as TSC -from tableauserverclient.models.task_item import TaskItem from tableauserverclient.datetime_helpers import parse_datetime +from tableauserverclient.models.task_item import TaskItem TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -17,8 +19,8 @@ class TaskTests(unittest.TestCase): def setUp(self): - self.server = TSC.Server("http://test") - self.server.version = '3.8' + self.server = TSC.Server("http://test", False) + self.server.version = "3.8" # Fake Signin self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" @@ -45,8 +47,8 @@ def test_get_tasks_with_workbook(self): all_tasks, pagination_item = self.server.tasks.get() task = all_tasks[0] - self.assertEqual('c7a9327e-1cda-4504-b026-ddb43b976d1d', task.target.id) - self.assertEqual('workbook', task.target.type) + self.assertEqual("c7a9327e-1cda-4504-b026-ddb43b976d1d", task.target.id) + self.assertEqual("workbook", task.target.type) def test_get_tasks_with_datasource(self): with open(GET_XML_WITH_DATASOURCE, "rb") as f: @@ -56,8 +58,8 @@ def test_get_tasks_with_datasource(self): all_tasks, pagination_item = self.server.tasks.get() task = all_tasks[0] - self.assertEqual('c7a9327e-1cda-4504-b026-ddb43b976d1d', task.target.id) - self.assertEqual('datasource', task.target.type) + self.assertEqual("c7a9327e-1cda-4504-b026-ddb43b976d1d", task.target.id) + self.assertEqual("datasource", task.target.type) def test_get_tasks_with_workbook_and_datasource(self): with open(GET_XML_WITH_WORKBOOK_AND_DATASOURCE, "rb") as f: @@ -66,9 +68,9 @@ def test_get_tasks_with_workbook_and_datasource(self): m.get(self.baseurl, text=response_xml) all_tasks, pagination_item = self.server.tasks.get() - self.assertEqual('workbook', all_tasks[0].target.type) - self.assertEqual('datasource', all_tasks[1].target.type) - self.assertEqual('workbook', all_tasks[2].target.type) + self.assertEqual("workbook", all_tasks[0].target.type) + self.assertEqual("datasource", all_tasks[1].target.type) + self.assertEqual("workbook", all_tasks[2].target.type) def test_get_task_with_schedule(self): with open(GET_XML_WITH_WORKBOOK, "rb") as f: @@ -78,63 +80,64 @@ def test_get_task_with_schedule(self): all_tasks, pagination_item = self.server.tasks.get() task = all_tasks[0] - self.assertEqual('c7a9327e-1cda-4504-b026-ddb43b976d1d', task.target.id) - self.assertEqual('workbook', task.target.type) - self.assertEqual('b60b4efd-a6f7-4599-beb3-cb677e7abac1', task.schedule_id) + self.assertEqual("c7a9327e-1cda-4504-b026-ddb43b976d1d", task.target.id) + self.assertEqual("workbook", task.target.type) + self.assertEqual("b60b4efd-a6f7-4599-beb3-cb677e7abac1", task.schedule_id) def test_delete(self): with requests_mock.mock() as m: - m.delete(self.baseurl + '/c7a9327e-1cda-4504-b026-ddb43b976d1d', status_code=204) - self.server.tasks.delete('c7a9327e-1cda-4504-b026-ddb43b976d1d') + m.delete(self.baseurl + "/c7a9327e-1cda-4504-b026-ddb43b976d1d", status_code=204) + self.server.tasks.delete("c7a9327e-1cda-4504-b026-ddb43b976d1d") def test_delete_missing_id(self): - self.assertRaises(ValueError, self.server.tasks.delete, '') + self.assertRaises(ValueError, self.server.tasks.delete, "") def test_get_materializeviews_tasks(self): with open(GET_XML_DATAACCELERATION_TASK, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get('{}/{}'.format( - self.server.tasks.baseurl, TaskItem.Type.DataAcceleration), text=response_xml) + m.get("{}/{}".format(self.server.tasks.baseurl, TaskItem.Type.DataAcceleration), text=response_xml) all_tasks, pagination_item = self.server.tasks.get(task_type=TaskItem.Type.DataAcceleration) task = all_tasks[0] - self.assertEqual('a462c148-fc40-4670-a8e4-39b7f0c58c7f', task.target.id) - self.assertEqual('workbook', task.target.type) - self.assertEqual('b22190b4-6ac2-4eed-9563-4afc03444413', task.schedule_id) - self.assertEqual(parse_datetime('2019-12-09T22:30:00Z'), task.schedule_item.next_run_at) - self.assertEqual(parse_datetime('2019-12-09T20:45:04Z'), task.last_run_at) + self.assertEqual("a462c148-fc40-4670-a8e4-39b7f0c58c7f", task.target.id) + self.assertEqual("workbook", task.target.type) + self.assertEqual("b22190b4-6ac2-4eed-9563-4afc03444413", task.schedule_id) + self.assertEqual(parse_datetime("2019-12-09T22:30:00Z"), task.schedule_item.next_run_at) + self.assertEqual(parse_datetime("2019-12-09T20:45:04Z"), task.last_run_at) self.assertEqual(TSC.TaskItem.Type.DataAcceleration, task.task_type) def test_delete_data_acceleration(self): with requests_mock.mock() as m: - m.delete('{}/{}/{}'.format( - self.server.tasks.baseurl, TaskItem.Type.DataAcceleration, - 'c9cff7f9-309c-4361-99ff-d4ba8c9f5467'), status_code=204) - self.server.tasks.delete('c9cff7f9-309c-4361-99ff-d4ba8c9f5467', - TaskItem.Type.DataAcceleration) + m.delete( + "{}/{}/{}".format( + self.server.tasks.baseurl, TaskItem.Type.DataAcceleration, "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" + ), + status_code=204, + ) + self.server.tasks.delete("c9cff7f9-309c-4361-99ff-d4ba8c9f5467", TaskItem.Type.DataAcceleration) def test_get_by_id(self): with open(GET_XML_WITH_WORKBOOK, "rb") as f: response_xml = f.read().decode("utf-8") - task_id = 'f84901ac-72ad-4f9b-a87e-7a3500402ad6' + task_id = "f84901ac-72ad-4f9b-a87e-7a3500402ad6" with requests_mock.mock() as m: - m.get('{}/{}'.format(self.baseurl, task_id), text=response_xml) + m.get("{}/{}".format(self.baseurl, task_id), text=response_xml) task = self.server.tasks.get_by_id(task_id) - self.assertEqual('c7a9327e-1cda-4504-b026-ddb43b976d1d', task.target.id) - self.assertEqual('workbook', task.target.type) - self.assertEqual('b60b4efd-a6f7-4599-beb3-cb677e7abac1', task.schedule_id) + self.assertEqual("c7a9327e-1cda-4504-b026-ddb43b976d1d", task.target.id) + self.assertEqual("workbook", task.target.type) + self.assertEqual("b60b4efd-a6f7-4599-beb3-cb677e7abac1", task.schedule_id) self.assertEqual(TSC.TaskItem.Type.ExtractRefresh, task.task_type) def test_run_now(self): - task_id = 'f84901ac-72ad-4f9b-a87e-7a3500402ad6' + task_id = "f84901ac-72ad-4f9b-a87e-7a3500402ad6" task = TaskItem(task_id, TaskItem.Type.ExtractRefresh, 100) with open(GET_XML_RUN_NOW_RESPONSE, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post('{}/{}/runNow'.format(self.baseurl, task_id), text=response_xml) + m.post("{}/{}/runNow".format(self.baseurl, task_id), text=response_xml) job_response_content = self.server.tasks.run(task).decode("utf-8") - self.assertTrue('7b6b59a8-ac3c-4d1d-2e9e-0b5b4ba8a7b6' in job_response_content) - self.assertTrue('RefreshExtract' in job_response_content) + self.assertTrue("7b6b59a8-ac3c-4d1d-2e9e-0b5b4ba8a7b6" in job_response_content) + self.assertTrue("RefreshExtract" in job_response_content) diff --git a/test/test_user.py b/test/test_user.py index e4d1d6717..6ba8ff7f2 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -1,34 +1,36 @@ -import unittest import os +import unittest + 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 = os.path.join(os.path.dirname(__file__), "assets") -GET_XML = os.path.join(TEST_ASSET_DIR, 'user_get.xml') -GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, 'user_get_empty.xml') -GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, 'user_get_by_id.xml') -UPDATE_XML = os.path.join(TEST_ASSET_DIR, 'user_update.xml') -ADD_XML = os.path.join(TEST_ASSET_DIR, 'user_add.xml') -POPULATE_WORKBOOKS_XML = os.path.join(TEST_ASSET_DIR, 'user_populate_workbooks.xml') -GET_FAVORITES_XML = os.path.join(TEST_ASSET_DIR, 'favorites_get.xml') -POPULATE_GROUPS_XML = os.path.join(TEST_ASSET_DIR, 'user_populate_groups.xml') +GET_XML = os.path.join(TEST_ASSET_DIR, "user_get.xml") +GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "user_get_empty.xml") +GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "user_get_by_id.xml") +UPDATE_XML = os.path.join(TEST_ASSET_DIR, "user_update.xml") +ADD_XML = os.path.join(TEST_ASSET_DIR, "user_add.xml") +POPULATE_WORKBOOKS_XML = os.path.join(TEST_ASSET_DIR, "user_populate_workbooks.xml") +GET_FAVORITES_XML = os.path.join(TEST_ASSET_DIR, "favorites_get.xml") +POPULATE_GROUPS_XML = os.path.join(TEST_ASSET_DIR, "user_populate_groups.xml") class UserTests(unittest.TestCase): - def setUp(self): - self.server = TSC.Server('http://test') + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) # Fake signin - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.baseurl = self.server.users.baseurl - def test_get(self): - with open(GET_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_get(self) -> None: + with open(GET_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.get(self.baseurl + "?fields=_all_", text=response_xml) all_users, pagination_item = self.server.users.get() @@ -36,24 +38,24 @@ def test_get(self): self.assertEqual(2, pagination_item.total_available) self.assertEqual(2, len(all_users)) - self.assertTrue(any(user.id == 'dd2239f6-ddf1-4107-981a-4cf94e415794' for user in all_users)) - single_user = next(user for user in all_users if user.id == 'dd2239f6-ddf1-4107-981a-4cf94e415794') - self.assertEqual('alice', single_user.name) - self.assertEqual('Publisher', single_user.site_role) - self.assertEqual('2016-08-16T23:17:06Z', format_datetime(single_user.last_login)) - self.assertEqual('alice cook', single_user.fullname) - self.assertEqual('alicecook@test.com', single_user.email) - - self.assertTrue(any(user.id == '2a47bbf8-8900-4ebb-b0a4-2723bd7c46c3' for user in all_users)) - single_user = next(user for user in all_users if user.id == '2a47bbf8-8900-4ebb-b0a4-2723bd7c46c3') - self.assertEqual('Bob', single_user.name) - self.assertEqual('Interactor', single_user.site_role) - self.assertEqual('Bob Smith', single_user.fullname) - self.assertEqual('bob@test.com', single_user.email) - - def test_get_empty(self): - with open(GET_EMPTY_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + self.assertTrue(any(user.id == "dd2239f6-ddf1-4107-981a-4cf94e415794" for user in all_users)) + single_user = next(user for user in all_users if user.id == "dd2239f6-ddf1-4107-981a-4cf94e415794") + self.assertEqual("alice", single_user.name) + self.assertEqual("Publisher", single_user.site_role) + self.assertEqual("2016-08-16T23:17:06Z", format_datetime(single_user.last_login)) + self.assertEqual("alice cook", single_user.fullname) + self.assertEqual("alicecook@test.com", single_user.email) + + self.assertTrue(any(user.id == "2a47bbf8-8900-4ebb-b0a4-2723bd7c46c3" for user in all_users)) + single_user = next(user for user in all_users if user.id == "2a47bbf8-8900-4ebb-b0a4-2723bd7c46c3") + self.assertEqual("Bob", single_user.name) + self.assertEqual("Interactor", single_user.site_role) + self.assertEqual("Bob Smith", single_user.fullname) + self.assertEqual("bob@test.com", single_user.email) + + def test_get_empty(self) -> None: + with open(GET_EMPTY_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) all_users, pagination_item = self.server.users.get() @@ -61,144 +63,142 @@ def test_get_empty(self): self.assertEqual(0, pagination_item.total_available) self.assertEqual([], all_users) - def test_get_before_signin(self): + def test_get_before_signin(self) -> None: self.server._auth_token = None self.assertRaises(TSC.NotSignedInError, self.server.users.get) - def test_get_by_id(self): - with open(GET_BY_ID_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_get_by_id(self) -> None: + with open(GET_BY_ID_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/dd2239f6-ddf1-4107-981a-4cf94e415794', text=response_xml) - single_user = self.server.users.get_by_id('dd2239f6-ddf1-4107-981a-4cf94e415794') - - self.assertEqual('dd2239f6-ddf1-4107-981a-4cf94e415794', single_user.id) - self.assertEqual('alice', single_user.name) - self.assertEqual('Alice', single_user.fullname) - self.assertEqual('Publisher', single_user.site_role) - self.assertEqual('ServerDefault', single_user.auth_setting) - self.assertEqual('2016-08-16T23:17:06Z', format_datetime(single_user.last_login)) - self.assertEqual('local', single_user.domain_name) - - def test_get_by_id_missing_id(self): - self.assertRaises(ValueError, self.server.users.get_by_id, '') - - def test_update(self): - with open(UPDATE_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + m.get(self.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794", text=response_xml) + single_user = self.server.users.get_by_id("dd2239f6-ddf1-4107-981a-4cf94e415794") + + self.assertEqual("dd2239f6-ddf1-4107-981a-4cf94e415794", single_user.id) + self.assertEqual("alice", single_user.name) + self.assertEqual("Alice", single_user.fullname) + self.assertEqual("Publisher", single_user.site_role) + self.assertEqual("ServerDefault", single_user.auth_setting) + self.assertEqual("2016-08-16T23:17:06Z", format_datetime(single_user.last_login)) + self.assertEqual("local", single_user.domain_name) + + def test_get_by_id_missing_id(self) -> None: + self.assertRaises(ValueError, self.server.users.get_by_id, "") + + def test_update(self) -> None: + with open(UPDATE_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.put(self.baseurl + '/dd2239f6-ddf1-4107-981a-4cf94e415794', text=response_xml) - single_user = TSC.UserItem('test', 'Viewer') - single_user._id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' - single_user.name = 'Cassie' - single_user.fullname = 'Cassie' - single_user.email = 'cassie@email.com' + m.put(self.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794", text=response_xml) + single_user = TSC.UserItem("test", "Viewer") + single_user._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + single_user.name = "Cassie" + single_user.fullname = "Cassie" + single_user.email = "cassie@email.com" single_user = self.server.users.update(single_user) - self.assertEqual('Cassie', single_user.name) - self.assertEqual('Cassie', single_user.fullname) - self.assertEqual('cassie@email.com', single_user.email) - self.assertEqual('Viewer', single_user.site_role) + self.assertEqual("Cassie", single_user.name) + self.assertEqual("Cassie", single_user.fullname) + self.assertEqual("cassie@email.com", single_user.email) + self.assertEqual("Viewer", single_user.site_role) - def test_update_missing_id(self): - single_user = TSC.UserItem('test', 'Interactor') + def test_update_missing_id(self) -> None: + single_user = TSC.UserItem("test", "Interactor") self.assertRaises(TSC.MissingRequiredFieldError, self.server.users.update, single_user) - def test_remove(self): + def test_remove(self) -> None: with requests_mock.mock() as m: - m.delete(self.baseurl + '/dd2239f6-ddf1-4107-981a-4cf94e415794', status_code=204) - self.server.users.remove('dd2239f6-ddf1-4107-981a-4cf94e415794') + m.delete(self.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794", status_code=204) + self.server.users.remove("dd2239f6-ddf1-4107-981a-4cf94e415794") - def test_remove_missing_id(self): - self.assertRaises(ValueError, self.server.users.remove, '') + def test_remove_missing_id(self) -> None: + self.assertRaises(ValueError, self.server.users.remove, "") - def test_add(self): - with open(ADD_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_add(self) -> None: + with open(ADD_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_user = TSC.UserItem(name='Cassie', site_role='Viewer', auth_setting='ServerDefault') + m.post(self.baseurl + "", text=response_xml) + new_user = TSC.UserItem(name="Cassie", site_role="Viewer", auth_setting="ServerDefault") new_user = self.server.users.add(new_user) - self.assertEqual('4cc4c17f-898a-4de4-abed-a1681c673ced', new_user.id) - self.assertEqual('Cassie', new_user.name) - self.assertEqual('Viewer', new_user.site_role) - self.assertEqual('ServerDefault', new_user.auth_setting) + self.assertEqual("4cc4c17f-898a-4de4-abed-a1681c673ced", new_user.id) + self.assertEqual("Cassie", new_user.name) + self.assertEqual("Viewer", new_user.site_role) + self.assertEqual("ServerDefault", new_user.auth_setting) - def test_populate_workbooks(self): - with open(POPULATE_WORKBOOKS_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_populate_workbooks(self) -> None: + with open(POPULATE_WORKBOOKS_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/dd2239f6-ddf1-4107-981a-4cf94e415794/workbooks', - text=response_xml) - single_user = TSC.UserItem('test', 'Interactor') - single_user._id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' + m.get(self.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794/workbooks", text=response_xml) + single_user = TSC.UserItem("test", "Interactor") + single_user._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" self.server.users.populate_workbooks(single_user) workbook_list = list(single_user.workbooks) - self.assertEqual('3cc6cd06-89ce-4fdc-b935-5294135d6d42', workbook_list[0].id) - self.assertEqual('SafariSample', workbook_list[0].name) - self.assertEqual('SafariSample', workbook_list[0].content_url) + self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", workbook_list[0].id) + self.assertEqual("SafariSample", workbook_list[0].name) + self.assertEqual("SafariSample", workbook_list[0].content_url) self.assertEqual(False, workbook_list[0].show_tabs) self.assertEqual(26, workbook_list[0].size) - self.assertEqual('2016-07-26T20:34:56Z', format_datetime(workbook_list[0].created_at)) - self.assertEqual('2016-07-26T20:35:05Z', format_datetime(workbook_list[0].updated_at)) - self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', workbook_list[0].project_id) - self.assertEqual('default', workbook_list[0].project_name) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', workbook_list[0].owner_id) - self.assertEqual(set(['Safari', 'Sample']), workbook_list[0].tags) - - def test_populate_workbooks_missing_id(self): - single_user = TSC.UserItem('test', 'Interactor') + self.assertEqual("2016-07-26T20:34:56Z", format_datetime(workbook_list[0].created_at)) + self.assertEqual("2016-07-26T20:35:05Z", format_datetime(workbook_list[0].updated_at)) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", workbook_list[0].project_id) + self.assertEqual("default", workbook_list[0].project_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", workbook_list[0].owner_id) + self.assertEqual(set(["Safari", "Sample"]), workbook_list[0].tags) + + def test_populate_workbooks_missing_id(self) -> None: + single_user = TSC.UserItem("test", "Interactor") self.assertRaises(TSC.MissingRequiredFieldError, self.server.users.populate_workbooks, single_user) - def test_populate_favorites(self): - self.server.version = '2.5' + def test_populate_favorites(self) -> None: + self.server.version = "2.5" baseurl = self.server.favorites.baseurl - single_user = TSC.UserItem('test', 'Interactor') - with open(GET_FAVORITES_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + single_user = TSC.UserItem("test", "Interactor") + with open(GET_FAVORITES_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get('{0}/{1}'.format(baseurl, single_user.id), text=response_xml) + m.get("{0}/{1}".format(baseurl, single_user.id), text=response_xml) self.server.users.populate_favorites(single_user) self.assertIsNotNone(single_user._favorites) - self.assertEqual(len(single_user.favorites['workbooks']), 1) - self.assertEqual(len(single_user.favorites['views']), 1) - self.assertEqual(len(single_user.favorites['projects']), 1) - self.assertEqual(len(single_user.favorites['datasources']), 1) - - workbook = single_user.favorites['workbooks'][0] - view = single_user.favorites['views'][0] - datasource = single_user.favorites['datasources'][0] - project = single_user.favorites['projects'][0] - - self.assertEqual(workbook.id, '6d13b0ca-043d-4d42-8c9d-3f3313ea3a00') - self.assertEqual(view.id, 'd79634e1-6063-4ec9-95ff-50acbf609ff5') - self.assertEqual(datasource.id, 'e76a1461-3b1d-4588-bf1b-17551a879ad9') - self.assertEqual(project.id, '1d0304cd-3796-429f-b815-7258370b9b74') - - def test_populate_groups(self): - self.server.version = '3.7' - with open(POPULATE_GROUPS_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + self.assertEqual(len(single_user.favorites["workbooks"]), 1) + self.assertEqual(len(single_user.favorites["views"]), 1) + self.assertEqual(len(single_user.favorites["projects"]), 1) + self.assertEqual(len(single_user.favorites["datasources"]), 1) + + workbook = single_user.favorites["workbooks"][0] + view = single_user.favorites["views"][0] + datasource = single_user.favorites["datasources"][0] + project = single_user.favorites["projects"][0] + + self.assertEqual(workbook.id, "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00") + self.assertEqual(view.id, "d79634e1-6063-4ec9-95ff-50acbf609ff5") + self.assertEqual(datasource.id, "e76a1461-3b1d-4588-bf1b-17551a879ad9") + self.assertEqual(project.id, "1d0304cd-3796-429f-b815-7258370b9b74") + + def test_populate_groups(self) -> None: + self.server.version = "3.7" + with open(POPULATE_GROUPS_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.server.users.baseurl + '/dd2239f6-ddf1-4107-981a-4cf94e415794/groups', - text=response_xml) - single_user = TSC.UserItem('test', 'Interactor') - single_user._id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' + m.get(self.server.users.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794/groups", text=response_xml) + single_user = TSC.UserItem("test", "Interactor") + single_user._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" self.server.users.populate_groups(single_user) group_list = list(single_user.groups) self.assertEqual(3, len(group_list)) - self.assertEqual('ef8b19c0-43b6-11e6-af50-63f5805dbe3c', group_list[0].id) - self.assertEqual('All Users', group_list[0].name) - self.assertEqual('local', group_list[0].domain_name) + self.assertEqual("ef8b19c0-43b6-11e6-af50-63f5805dbe3c", group_list[0].id) + self.assertEqual("All Users", group_list[0].name) + self.assertEqual("local", group_list[0].domain_name) - self.assertEqual('e7833b48-c6f7-47b5-a2a7-36e7dd232758', group_list[1].id) - self.assertEqual('Another group', group_list[1].name) - self.assertEqual('local', group_list[1].domain_name) + self.assertEqual("e7833b48-c6f7-47b5-a2a7-36e7dd232758", group_list[1].id) + self.assertEqual("Another group", group_list[1].name) + self.assertEqual("local", group_list[1].domain_name) - self.assertEqual('86a66d40-f289-472a-83d0-927b0f954dc8', group_list[2].id) - self.assertEqual('TableauExample', group_list[2].name) - self.assertEqual('local', group_list[2].domain_name) + self.assertEqual("86a66d40-f289-472a-83d0-927b0f954dc8", group_list[2].id) + self.assertEqual("TableauExample", group_list[2].name) + self.assertEqual("local", group_list[2].domain_name) diff --git a/test/test_user_model.py b/test/test_user_model.py index 5826fb148..ba70b1c7c 100644 --- a/test/test_user_model.py +++ b/test/test_user_model.py @@ -1,4 +1,5 @@ import unittest + import tableauserverclient as TSC diff --git a/test/test_view.py b/test/test_view.py index e32971ea2..3562650d1 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -1,114 +1,117 @@ -import unittest import os +import unittest + import requests_mock -import tableauserverclient as TSC -from tableauserverclient.datetime_helpers import format_datetime +import tableauserverclient as TSC from tableauserverclient import UserItem, GroupItem, PermissionsRule +from tableauserverclient.datetime_helpers import format_datetime -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") -ADD_TAGS_XML = os.path.join(TEST_ASSET_DIR, 'view_add_tags.xml') -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') -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') -POPULATE_PERMISSIONS_XML = os.path.join(TEST_ASSET_DIR, 'view_populate_permissions.xml') -UPDATE_PERMISSIONS = os.path.join(TEST_ASSET_DIR, 'view_update_permissions.xml') -UPDATE_XML = os.path.join(TEST_ASSET_DIR, 'workbook_update.xml') +ADD_TAGS_XML = os.path.join(TEST_ASSET_DIR, "view_add_tags.xml") +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") +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") +POPULATE_EXCEL = os.path.join(TEST_ASSET_DIR, "populate_excel.xlsx") +POPULATE_PERMISSIONS_XML = os.path.join(TEST_ASSET_DIR, "view_populate_permissions.xml") +UPDATE_PERMISSIONS = os.path.join(TEST_ASSET_DIR, "view_update_permissions.xml") +UPDATE_XML = os.path.join(TEST_ASSET_DIR, "workbook_update.xml") class ViewTests(unittest.TestCase): def setUp(self): - self.server = TSC.Server('http://test') - self.server.version = '3.2' + self.server = TSC.Server("http://test", False) + self.server.version = "3.2" # Fake sign in - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.baseurl = self.server.views.baseurl self.siteurl = self.server.views.siteurl - def test_get(self): - with open(GET_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_get(self) -> None: + with open(GET_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) all_views, pagination_item = self.server.views.get() self.assertEqual(2, pagination_item.total_available) - self.assertEqual('d79634e1-6063-4ec9-95ff-50acbf609ff5', all_views[0].id) - self.assertEqual('ENDANGERED SAFARI', all_views[0].name) - self.assertEqual('SafariSample/sheets/ENDANGEREDSAFARI', all_views[0].content_url) - self.assertEqual('3cc6cd06-89ce-4fdc-b935-5294135d6d42', all_views[0].workbook_id) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_views[0].owner_id) - self.assertEqual('5241e88d-d384-4fd7-9c2f-648b5247efc5', all_views[0].project_id) - self.assertEqual(set(['tag1', 'tag2']), all_views[0].tags) + self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", all_views[0].id) + self.assertEqual("ENDANGERED SAFARI", all_views[0].name) + self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", all_views[0].content_url) + self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", all_views[0].workbook_id) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_views[0].owner_id) + self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", all_views[0].project_id) + self.assertEqual(set(["tag1", "tag2"]), all_views[0].tags) self.assertIsNone(all_views[0].created_at) self.assertIsNone(all_views[0].updated_at) self.assertIsNone(all_views[0].sheet_type) - self.assertEqual('fd252f73-593c-4c4e-8584-c032b8022adc', all_views[1].id) - self.assertEqual('Overview', all_views[1].name) - self.assertEqual('Superstore/sheets/Overview', all_views[1].content_url) - 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('5b534f74-3226-11e8-b47a-cb2e00f738a3', all_views[1].project_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.assertEqual('story', all_views[1].sheet_type) - - def test_get_by_id(self): - with open(GET_XML_ID, 'rb') as f: - response_xml = f.read().decode('utf-8') + self.assertEqual("fd252f73-593c-4c4e-8584-c032b8022adc", all_views[1].id) + self.assertEqual("Overview", all_views[1].name) + self.assertEqual("Superstore/sheets/Overview", all_views[1].content_url) + 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("5b534f74-3226-11e8-b47a-cb2e00f738a3", all_views[1].project_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.assertEqual("story", all_views[1].sheet_type) + + def test_get_by_id(self) -> None: + with open(GET_XML_ID, "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', text=response_xml) - view = self.server.views.get_by_id('d79634e1-6063-4ec9-95ff-50acbf609ff5') - - 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) - - def test_get_by_id_missing_id(self): + m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5", text=response_xml) + view = self.server.views.get_by_id("d79634e1-6063-4ec9-95ff-50acbf609ff5") + + 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) + + def test_get_by_id_missing_id(self) -> None: self.assertRaises(TSC.MissingRequiredFieldError, self.server.views.get_by_id, None) - def test_get_with_usage(self): - with open(GET_XML_USAGE, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_get_with_usage(self) -> None: + with open(GET_XML_USAGE, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.get(self.baseurl + "?includeUsageStatistics=true", text=response_xml) all_views, pagination_item = self.server.views.get(usage=True) - self.assertEqual('d79634e1-6063-4ec9-95ff-50acbf609ff5', all_views[0].id) + self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", all_views[0].id) self.assertEqual(7, all_views[0].total_views) self.assertIsNone(all_views[0].created_at) self.assertIsNone(all_views[0].updated_at) self.assertIsNone(all_views[0].sheet_type) - self.assertEqual('fd252f73-593c-4c4e-8584-c032b8022adc', all_views[1].id) + self.assertEqual("fd252f73-593c-4c4e-8584-c032b8022adc", all_views[1].id) self.assertEqual(13, all_views[1].total_views) - 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.assertEqual('story', all_views[1].sheet_type) + 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.assertEqual("story", all_views[1].sheet_type) - def test_get_with_usage_and_filter(self): - with open(GET_XML_USAGE, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_get_with_usage_and_filter(self) -> None: + with open(GET_XML_USAGE, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.get(self.baseurl + "?includeUsageStatistics=true&filter=name:in:[foo,bar]", text=response_xml) options = TSC.RequestOptions() - options.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.In, - ["foo", "bar"])) + options.filter.add( + TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.In, ["foo", "bar"]) + ) all_views, pagination_item = self.server.views.get(req_options=options, usage=True) self.assertEqual("ENDANGERED SAFARI", all_views[0].name) @@ -116,61 +119,67 @@ def test_get_with_usage_and_filter(self): self.assertEqual("Overview", all_views[1].name) self.assertEqual(13, all_views[1].total_views) - def test_get_before_signin(self): + def test_get_before_signin(self) -> None: self.server._auth_token = None self.assertRaises(TSC.NotSignedInError, self.server.views.get) - def test_populate_preview_image(self): - with open(POPULATE_PREVIEW_IMAGE, 'rb') as f: + def test_populate_preview_image(self) -> None: + with open(POPULATE_PREVIEW_IMAGE, "rb") as f: response = f.read() with requests_mock.mock() as m: - m.get(self.siteurl + '/workbooks/3cc6cd06-89ce-4fdc-b935-5294135d6d42/' - 'views/d79634e1-6063-4ec9-95ff-50acbf609ff5/previewImage', content=response) + m.get( + self.siteurl + "/workbooks/3cc6cd06-89ce-4fdc-b935-5294135d6d42/" + "views/d79634e1-6063-4ec9-95ff-50acbf609ff5/previewImage", + content=response, + ) single_view = TSC.ViewItem() - single_view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' - single_view._workbook_id = '3cc6cd06-89ce-4fdc-b935-5294135d6d42' + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + single_view._workbook_id = "3cc6cd06-89ce-4fdc-b935-5294135d6d42" self.server.views.populate_preview_image(single_view) self.assertEqual(response, single_view.preview_image) - def test_populate_preview_image_missing_id(self): + def test_populate_preview_image_missing_id(self) -> None: single_view = TSC.ViewItem() - single_view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" self.assertRaises(TSC.MissingRequiredFieldError, self.server.views.populate_preview_image, single_view) single_view._id = None - single_view._workbook_id = '3cc6cd06-89ce-4fdc-b935-5294135d6d42' + single_view._workbook_id = "3cc6cd06-89ce-4fdc-b935-5294135d6d42" self.assertRaises(TSC.MissingRequiredFieldError, self.server.views.populate_preview_image, single_view) - def test_populate_image(self): - with open(POPULATE_PREVIEW_IMAGE, 'rb') as f: + def test_populate_image(self) -> None: + with open(POPULATE_PREVIEW_IMAGE, "rb") as f: response = f.read() with requests_mock.mock() as m: - m.get(self.baseurl + '/d79634e1-6063-4ec9-95ff-50acbf609ff5/image', content=response) + m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image", content=response) single_view = TSC.ViewItem() - single_view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" self.server.views.populate_image(single_view) self.assertEqual(response, single_view.image) - def test_populate_image_with_options(self): - with open(POPULATE_PREVIEW_IMAGE, 'rb') as f: + def test_populate_image_with_options(self) -> None: + with open(POPULATE_PREVIEW_IMAGE, "rb") as f: response = f.read() with requests_mock.mock() as m: - m.get(self.baseurl + '/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?resolution=high&maxAge=10', - content=response) + m.get( + self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?resolution=high&maxAge=10", content=response + ) single_view = TSC.ViewItem() - single_view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" req_option = TSC.ImageRequestOptions(imageresolution=TSC.ImageRequestOptions.Resolution.High, maxage=10) self.server.views.populate_image(single_view, req_option) self.assertEqual(response, single_view.image) - def test_populate_pdf(self): - with open(POPULATE_PDF, 'rb') as f: + def test_populate_pdf(self) -> None: + 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?type=letter&orientation=portrait&maxAge=5', - content=response) + m.get( + self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/pdf?type=letter&orientation=portrait&maxAge=5", + content=response, + ) single_view = TSC.ViewItem() - single_view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" size = TSC.PDFRequestOptions.PageType.Letter orientation = TSC.PDFRequestOptions.Orientation.Portrait @@ -179,39 +188,39 @@ def test_populate_pdf(self): self.server.views.populate_pdf(single_view, req_option) self.assertEqual(response, single_view.pdf) - def test_populate_csv(self): - with open(POPULATE_CSV, 'rb') as f: + def test_populate_csv(self) -> None: + with open(POPULATE_CSV, "rb") as f: response = f.read() with requests_mock.mock() as m: - m.get(self.baseurl + '/d79634e1-6063-4ec9-95ff-50acbf609ff5/data?maxAge=1', content=response) + m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/data?maxAge=1", content=response) single_view = TSC.ViewItem() - single_view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" request_option = TSC.CSVRequestOptions(maxage=1) self.server.views.populate_csv(single_view, request_option) csv_file = b"".join(single_view.csv) self.assertEqual(response, csv_file) - def test_populate_csv_default_maxage(self): - with open(POPULATE_CSV, 'rb') as f: + def test_populate_csv_default_maxage(self) -> None: + with open(POPULATE_CSV, "rb") as f: response = f.read() with requests_mock.mock() as m: - m.get(self.baseurl + '/d79634e1-6063-4ec9-95ff-50acbf609ff5/data', content=response) + m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/data", content=response) single_view = TSC.ViewItem() - single_view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" self.server.views.populate_csv(single_view) csv_file = b"".join(single_view.csv) self.assertEqual(response, csv_file) - def test_populate_image_missing_id(self): + def test_populate_image_missing_id(self) -> None: single_view = TSC.ViewItem() single_view._id = None self.assertRaises(TSC.MissingRequiredFieldError, self.server.views.populate_image, single_view) - def test_populate_permissions(self): - with open(POPULATE_PERMISSIONS_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_populate_permissions(self) -> None: + with open(POPULATE_PERMISSIONS_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.get(self.baseurl + "/e490bec4-2652-4fda-8c4e-f087db6fa328/permissions", text=response_xml) single_view = TSC.ViewItem() @@ -220,63 +229,73 @@ def test_populate_permissions(self): self.server.views.populate_permissions(single_view) permissions = single_view.permissions - self.assertEqual(permissions[0].grantee.tag_name, 'group') - self.assertEqual(permissions[0].grantee.id, 'c8f2773a-c83a-11e8-8c8f-33e6d787b506') - self.assertDictEqual(permissions[0].capabilities, { - TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.AddComment: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportImage: TSC.Permission.Mode.Allow, - - }) - - def test_add_permissions(self): - with open(UPDATE_PERMISSIONS, 'rb') as f: - response_xml = f.read().decode('utf-8') + self.assertEqual(permissions[0].grantee.tag_name, "group") + self.assertEqual(permissions[0].grantee.id, "c8f2773a-c83a-11e8-8c8f-33e6d787b506") + self.assertDictEqual( + permissions[0].capabilities, + { + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.AddComment: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportImage: TSC.Permission.Mode.Allow, + }, + ) + + def test_add_permissions(self) -> None: + with open(UPDATE_PERMISSIONS, "rb") as f: + response_xml = f.read().decode("utf-8") single_view = TSC.ViewItem() - single_view._id = '21778de4-b7b9-44bc-a599-1506a2639ace' + single_view._id = "21778de4-b7b9-44bc-a599-1506a2639ace" bob = UserItem.as_reference("7c37ee24-c4b1-42b6-a154-eaeab7ee330a") group_of_people = GroupItem.as_reference("5e5e1978-71fa-11e4-87dd-7382f5c437af") - new_permissions = [ - PermissionsRule(bob, {'Write': 'Allow'}), - PermissionsRule(group_of_people, {'Read': 'Deny'}) - ] + new_permissions = [PermissionsRule(bob, {"Write": "Allow"}), PermissionsRule(group_of_people, {"Read": "Deny"})] with requests_mock.mock() as m: m.put(self.baseurl + "/21778de4-b7b9-44bc-a599-1506a2639ace/permissions", text=response_xml) permissions = self.server.views.update_permissions(single_view, new_permissions) - self.assertEqual(permissions[0].grantee.tag_name, 'group') - self.assertEqual(permissions[0].grantee.id, '5e5e1978-71fa-11e4-87dd-7382f5c437af') - self.assertDictEqual(permissions[0].capabilities, { - TSC.Permission.Capability.Read: TSC.Permission.Mode.Deny - }) - - self.assertEqual(permissions[1].grantee.tag_name, 'user') - self.assertEqual(permissions[1].grantee.id, '7c37ee24-c4b1-42b6-a154-eaeab7ee330a') - self.assertDictEqual(permissions[1].capabilities, { - TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow - }) - - def test_update_tags(self): - with open(ADD_TAGS_XML, 'rb') as f: - add_tags_xml = f.read().decode('utf-8') - with open(UPDATE_XML, 'rb') as f: - update_xml = f.read().decode('utf-8') + self.assertEqual(permissions[0].grantee.tag_name, "group") + self.assertEqual(permissions[0].grantee.id, "5e5e1978-71fa-11e4-87dd-7382f5c437af") + self.assertDictEqual(permissions[0].capabilities, {TSC.Permission.Capability.Read: TSC.Permission.Mode.Deny}) + + self.assertEqual(permissions[1].grantee.tag_name, "user") + self.assertEqual(permissions[1].grantee.id, "7c37ee24-c4b1-42b6-a154-eaeab7ee330a") + self.assertDictEqual(permissions[1].capabilities, {TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow}) + + def test_update_tags(self) -> None: + with open(ADD_TAGS_XML, "rb") as f: + add_tags_xml = f.read().decode("utf-8") + with open(UPDATE_XML, "rb") as f: + update_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.put(self.baseurl + '/d79634e1-6063-4ec9-95ff-50acbf609ff5/tags', text=add_tags_xml) - m.delete(self.baseurl + '/d79634e1-6063-4ec9-95ff-50acbf609ff5/tags/b', status_code=204) - m.delete(self.baseurl + '/d79634e1-6063-4ec9-95ff-50acbf609ff5/tags/d', status_code=204) - m.put(self.baseurl + '/d79634e1-6063-4ec9-95ff-50acbf609ff5', text=update_xml) + m.put(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/tags", text=add_tags_xml) + m.delete(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/tags/b", status_code=204) + m.delete(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/tags/d", status_code=204) + m.put(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5", text=update_xml) single_view = TSC.ViewItem() - single_view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' - single_view._initial_tags.update(['a', 'b', 'c', 'd']) - single_view.tags.update(['a', 'c', 'e']) + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + single_view._initial_tags.update(["a", "b", "c", "d"]) + single_view.tags.update(["a", "c", "e"]) updated_view = self.server.views.update(single_view) self.assertEqual(single_view.tags, updated_view.tags) self.assertEqual(single_view._initial_tags, updated_view._initial_tags) + + def test_populate_excel(self) -> None: + self.server.version = "3.8" + self.baseurl = self.server.views.baseurl + with open(POPULATE_EXCEL, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/crosstab/excel?maxAge=1", content=response) + single_view = TSC.ViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + request_option = TSC.CSVRequestOptions(maxage=1) + self.server.views.populate_excel(single_view, request_option) + + excel_file = b"".join(single_view.excel) + self.assertEqual(response, excel_file) diff --git a/test/test_webhook.py b/test/test_webhook.py index 819de18ae..ff8b7048e 100644 --- a/test/test_webhook.py +++ b/test/test_webhook.py @@ -1,32 +1,33 @@ -import unittest import os +import unittest + import requests_mock + import tableauserverclient as TSC from tableauserverclient.server import RequestFactory, WebhookItem +from ._utils import asset -from ._utils import read_xml_asset, read_xml_assets, asset - -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") -GET_XML = asset('webhook_get.xml') -CREATE_XML = asset('webhook_create.xml') -CREATE_REQUEST_XML = asset('webhook_create_request.xml') +GET_XML = asset("webhook_get.xml") +CREATE_XML = asset("webhook_create.xml") +CREATE_REQUEST_XML = asset("webhook_create_request.xml") class WebhookTests(unittest.TestCase): - def setUp(self): - self.server = TSC.Server('http://test') + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) self.server.version = "3.6" # Fake signin - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.baseurl = self.server.webhooks.baseurl - def test_get(self): - with open(GET_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_get(self) -> None: + with open(GET_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) webhooks, _ = self.server.webhooks.get() @@ -39,26 +40,26 @@ def test_get(self): self.assertEqual(webhook.name, "webhook-name") self.assertEqual(webhook.id, "webhook-id") - def test_get_before_signin(self): + def test_get_before_signin(self) -> None: self.server._auth_token = None self.assertRaises(TSC.NotSignedInError, self.server.webhooks.get) - def test_delete(self): + def test_delete(self) -> None: with requests_mock.mock() as m: - m.delete(self.baseurl + '/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', status_code=204) - self.server.webhooks.delete('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + m.delete(self.baseurl + "/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", status_code=204) + self.server.webhooks.delete("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") - def test_delete_missing_id(self): - self.assertRaises(ValueError, self.server.webhooks.delete, '') + def test_delete_missing_id(self) -> None: + self.assertRaises(ValueError, self.server.webhooks.delete, "") - def test_test(self): + def test_test(self) -> None: with requests_mock.mock() as m: - m.get(self.baseurl + '/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760/test', status_code=200) - self.server.webhooks.test('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + m.get(self.baseurl + "/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760/test", status_code=200) + self.server.webhooks.test("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") - def test_create(self): - with open(CREATE_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_create(self) -> None: + with open(CREATE_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=response_xml) webhook_model = TSC.WebhookItem() @@ -71,13 +72,12 @@ def test_create(self): self.assertNotEqual(new_webhook.id, None) def test_request_factory(self): - with open(CREATE_REQUEST_XML, 'rb') as f: - webhook_request_expected = f.read().decode('utf-8') + with open(CREATE_REQUEST_XML, "rb") as f: + webhook_request_expected = f.read().decode("utf-8") webhook_item = WebhookItem() - webhook_item._set_values("webhook-id", "webhook-name", "url", "api-event-name", - None) - webhook_request_actual = '{}\n'.format(RequestFactory.Webhook.create_req(webhook_item).decode('utf-8')) + webhook_item._set_values("webhook-id", "webhook-name", "url", "api-event-name", None) + webhook_request_actual = "{}\n".format(RequestFactory.Webhook.create_req(webhook_item).decode("utf-8")) self.maxDiff = None # windows does /r/n for linebreaks, remove the extra char if it is there - self.assertEqual(webhook_request_expected.replace('\r', ''), webhook_request_actual) + self.assertEqual(webhook_request_expected.replace("\r", ""), webhook_request_actual) diff --git a/test/test_workbook.py b/test/test_workbook.py index 459b1f905..db7f0723b 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -1,103 +1,108 @@ -import unittest -from io import BytesIO import os import re import requests_mock import tableauserverclient as TSC +import tempfile +import unittest import xml.etree.ElementTree as ET +from defusedxml.ElementTree import fromstring +from io import BytesIO +from pathlib import Path +import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime -from tableauserverclient.server.endpoint.exceptions import InternalServerError -from tableauserverclient.server.request_factory import RequestFactory +from tableauserverclient.models.group_item import GroupItem from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.models.user_item import UserItem -from tableauserverclient.models.group_item import GroupItem - +from tableauserverclient.server.endpoint.exceptions import InternalServerError +from tableauserverclient.server.request_factory import RequestFactory from ._utils import asset -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') - -ADD_TAGS_XML = os.path.join(TEST_ASSET_DIR, 'workbook_add_tags.xml') -GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, 'workbook_get_by_id.xml') -GET_BY_ID_XML_PERSONAL = os.path.join(TEST_ASSET_DIR, 'workbook_get_by_id_personal.xml') -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') -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_PERMISSIONS_XML = os.path.join(TEST_ASSET_DIR, 'workbook_populate_permissions.xml') -POPULATE_PREVIEW_IMAGE = os.path.join(TEST_ASSET_DIR, 'RESTAPISample Image.png') -POPULATE_VIEWS_XML = os.path.join(TEST_ASSET_DIR, 'workbook_populate_views.xml') -POPULATE_VIEWS_USAGE_XML = os.path.join(TEST_ASSET_DIR, 'workbook_populate_views_usage.xml') -PUBLISH_XML = os.path.join(TEST_ASSET_DIR, 'workbook_publish.xml') -PUBLISH_ASYNC_XML = os.path.join(TEST_ASSET_DIR, 'workbook_publish_async.xml') -REFRESH_XML = os.path.join(TEST_ASSET_DIR, 'workbook_refresh.xml') -UPDATE_XML = os.path.join(TEST_ASSET_DIR, 'workbook_update.xml') -UPDATE_PERMISSIONS = os.path.join(TEST_ASSET_DIR, 'workbook_update_permissions.xml') +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") + +ADD_TAGS_XML = os.path.join(TEST_ASSET_DIR, "workbook_add_tags.xml") +GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_by_id.xml") +GET_BY_ID_XML_PERSONAL = os.path.join(TEST_ASSET_DIR, "workbook_get_by_id_personal.xml") +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") +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") +POPULATE_PERMISSIONS_XML = os.path.join(TEST_ASSET_DIR, "workbook_populate_permissions.xml") +POPULATE_PREVIEW_IMAGE = os.path.join(TEST_ASSET_DIR, "RESTAPISample Image.png") +POPULATE_VIEWS_XML = os.path.join(TEST_ASSET_DIR, "workbook_populate_views.xml") +POPULATE_VIEWS_USAGE_XML = os.path.join(TEST_ASSET_DIR, "workbook_populate_views_usage.xml") +PUBLISH_XML = os.path.join(TEST_ASSET_DIR, "workbook_publish.xml") +PUBLISH_ASYNC_XML = os.path.join(TEST_ASSET_DIR, "workbook_publish_async.xml") +REFRESH_XML = os.path.join(TEST_ASSET_DIR, "workbook_refresh.xml") +REVISION_XML = os.path.join(TEST_ASSET_DIR, "workbook_revision.xml") +UPDATE_XML = os.path.join(TEST_ASSET_DIR, "workbook_update.xml") +UPDATE_PERMISSIONS = os.path.join(TEST_ASSET_DIR, "workbook_update_permissions.xml") class WorkbookTests(unittest.TestCase): - def setUp(self): - self.server = TSC.Server('http://test') + 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.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.baseurl = self.server.workbooks.baseurl - def test_get(self): - with open(GET_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_get(self) -> None: + with open(GET_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) all_workbooks, pagination_item = self.server.workbooks.get() self.assertEqual(2, pagination_item.total_available) - self.assertEqual('6d13b0ca-043d-4d42-8c9d-3f3313ea3a00', all_workbooks[0].id) - self.assertEqual('Superstore', all_workbooks[0].name) - self.assertEqual('Superstore', all_workbooks[0].content_url) + self.assertEqual("6d13b0ca-043d-4d42-8c9d-3f3313ea3a00", all_workbooks[0].id) + self.assertEqual("Superstore", all_workbooks[0].name) + self.assertEqual("Superstore", all_workbooks[0].content_url) self.assertEqual(False, all_workbooks[0].show_tabs) - self.assertEqual('http://tableauserver/#/workbooks/1/views', all_workbooks[0].webpage_url) + self.assertEqual("http://tableauserver/#/workbooks/1/views", all_workbooks[0].webpage_url) self.assertEqual(1, all_workbooks[0].size) - self.assertEqual('2016-08-03T20:34:04Z', format_datetime(all_workbooks[0].created_at)) - self.assertEqual('description for Superstore', all_workbooks[0].description) - self.assertEqual('2016-08-04T17:56:41Z', format_datetime(all_workbooks[0].updated_at)) - self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', all_workbooks[0].project_id) - self.assertEqual('default', all_workbooks[0].project_name) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_workbooks[0].owner_id) - - self.assertEqual('3cc6cd06-89ce-4fdc-b935-5294135d6d42', all_workbooks[1].id) - self.assertEqual('SafariSample', all_workbooks[1].name) - self.assertEqual('SafariSample', all_workbooks[1].content_url) - self.assertEqual('http://tableauserver/#/workbooks/2/views', all_workbooks[1].webpage_url) + self.assertEqual("2016-08-03T20:34:04Z", format_datetime(all_workbooks[0].created_at)) + self.assertEqual("description for Superstore", all_workbooks[0].description) + self.assertEqual("2016-08-04T17:56:41Z", format_datetime(all_workbooks[0].updated_at)) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", all_workbooks[0].project_id) + self.assertEqual("default", all_workbooks[0].project_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_workbooks[0].owner_id) + + self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", all_workbooks[1].id) + self.assertEqual("SafariSample", all_workbooks[1].name) + self.assertEqual("SafariSample", all_workbooks[1].content_url) + self.assertEqual("http://tableauserver/#/workbooks/2/views", all_workbooks[1].webpage_url) self.assertEqual(False, all_workbooks[1].show_tabs) self.assertEqual(26, all_workbooks[1].size) - self.assertEqual('2016-07-26T20:34:56Z', format_datetime(all_workbooks[1].created_at)) - self.assertEqual('description for SafariSample', all_workbooks[1].description) - self.assertEqual('2016-07-26T20:35:05Z', format_datetime(all_workbooks[1].updated_at)) - self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', all_workbooks[1].project_id) - self.assertEqual('default', all_workbooks[1].project_name) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_workbooks[1].owner_id) - self.assertEqual(set(['Safari', 'Sample']), all_workbooks[1].tags) - - def test_get_ignore_invalid_date(self): - with open(GET_INVALID_DATE_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + self.assertEqual("2016-07-26T20:34:56Z", format_datetime(all_workbooks[1].created_at)) + self.assertEqual("description for SafariSample", all_workbooks[1].description) + self.assertEqual("2016-07-26T20:35:05Z", format_datetime(all_workbooks[1].updated_at)) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", all_workbooks[1].project_id) + self.assertEqual("default", all_workbooks[1].project_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_workbooks[1].owner_id) + self.assertEqual(set(["Safari", "Sample"]), all_workbooks[1].tags) + + def test_get_ignore_invalid_date(self) -> None: + with open(GET_INVALID_DATE_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) all_workbooks, pagination_item = self.server.workbooks.get() self.assertEqual(None, format_datetime(all_workbooks[0].created_at)) - self.assertEqual('2016-08-04T17:56:41Z', format_datetime(all_workbooks[0].updated_at)) + self.assertEqual("2016-08-04T17:56:41Z", format_datetime(all_workbooks[0].updated_at)) - def test_get_before_signin(self): + def test_get_before_signin(self) -> None: self.server._auth_token = None self.assertRaises(TSC.NotSignedInError, self.server.workbooks.get) - def test_get_empty(self): - with open(GET_EMPTY_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_get_empty(self) -> None: + with open(GET_EMPTY_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) all_workbooks, pagination_item = self.server.workbooks.get() @@ -105,125 +110,125 @@ def test_get_empty(self): self.assertEqual(0, pagination_item.total_available) self.assertEqual([], all_workbooks) - def test_get_by_id(self): - with open(GET_BY_ID_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_get_by_id(self) -> None: + with open(GET_BY_ID_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') + 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("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) - - def test_get_by_id_personal(self): + 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) + + def test_get_by_id_personal(self) -> None: # workbooks in personal space don't have project_id or project_name - with open(GET_BY_ID_XML_PERSONAL, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(GET_BY_ID_XML_PERSONAL, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d43', text=response_xml) - single_workbook = self.server.workbooks.get_by_id('3cc6cd06-89ce-4fdc-b935-5294135d6d43') + m.get(self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d43", text=response_xml) + single_workbook = self.server.workbooks.get_by_id("3cc6cd06-89ce-4fdc-b935-5294135d6d43") - self.assertEqual('3cc6cd06-89ce-4fdc-b935-5294135d6d43', 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("3cc6cd06-89ce-4fdc-b935-5294135d6d43", 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("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.assertTrue(single_workbook.project_id) self.assertIsNone(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("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) - def test_get_by_id_missing_id(self): - self.assertRaises(ValueError, self.server.workbooks.get_by_id, '') + def test_get_by_id_missing_id(self) -> None: + self.assertRaises(ValueError, self.server.workbooks.get_by_id, "") - def test_refresh_id(self): - self.server.version = '2.8' + def test_refresh_id(self) -> None: + self.server.version = "2.8" self.baseurl = self.server.workbooks.baseurl - with open(REFRESH_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(REFRESH_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/refresh', - status_code=202, text=response_xml) - self.server.workbooks.refresh('3cc6cd06-89ce-4fdc-b935-5294135d6d42') + m.post(self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/refresh", status_code=202, text=response_xml) + self.server.workbooks.refresh("3cc6cd06-89ce-4fdc-b935-5294135d6d42") - def test_refresh_object(self): - self.server.version = '2.8' + def test_refresh_object(self) -> None: + self.server.version = "2.8" self.baseurl = self.server.workbooks.baseurl - workbook = TSC.WorkbookItem('') - workbook._id = '3cc6cd06-89ce-4fdc-b935-5294135d6d42' - with open(REFRESH_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + workbook = TSC.WorkbookItem("") + workbook._id = "3cc6cd06-89ce-4fdc-b935-5294135d6d42" + with open(REFRESH_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/refresh', - status_code=202, text=response_xml) + m.post(self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/refresh", status_code=202, text=response_xml) self.server.workbooks.refresh(workbook) - def test_delete(self): + def test_delete(self) -> None: with requests_mock.mock() as m: - m.delete(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42', status_code=204) - self.server.workbooks.delete('3cc6cd06-89ce-4fdc-b935-5294135d6d42') + m.delete(self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42", status_code=204) + self.server.workbooks.delete("3cc6cd06-89ce-4fdc-b935-5294135d6d42") - def test_delete_missing_id(self): - self.assertRaises(ValueError, self.server.workbooks.delete, '') + def test_delete_missing_id(self) -> None: + self.assertRaises(ValueError, self.server.workbooks.delete, "") - def test_update(self): - with open(UPDATE_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_update(self) -> None: + with open(UPDATE_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.owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' - single_workbook.name = 'renamedWorkbook' - single_workbook.data_acceleration_config = {'acceleration_enabled': True, - 'accelerate_now': False, - 'last_updated_at': None, - 'acceleration_status': None} + 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.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + single_workbook.name = "renamedWorkbook" + single_workbook.data_acceleration_config = { + "acceleration_enabled": True, + "accelerate_now": False, + "last_updated_at": None, + "acceleration_status": None, + } single_workbook = self.server.workbooks.update(single_workbook) - self.assertEqual('1f951daf-4061-451a-9df1-69a8062664f2', single_workbook.id) + self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id) self.assertEqual(True, single_workbook.show_tabs) - self.assertEqual('1d0304cd-3796-429f-b815-7258370b9b74', single_workbook.project_id) - self.assertEqual('dd2239f6-ddf1-4107-981a-4cf94e415794', single_workbook.owner_id) - self.assertEqual('renamedWorkbook', single_workbook.name) - self.assertEqual(True, single_workbook.data_acceleration_config['acceleration_enabled']) - self.assertEqual(False, single_workbook.data_acceleration_config['accelerate_now']) - - def test_update_missing_id(self): - single_workbook = TSC.WorkbookItem('test') + self.assertEqual("1d0304cd-3796-429f-b815-7258370b9b74", single_workbook.project_id) + self.assertEqual("dd2239f6-ddf1-4107-981a-4cf94e415794", single_workbook.owner_id) + self.assertEqual("renamedWorkbook", single_workbook.name) + self.assertEqual(True, single_workbook.data_acceleration_config["acceleration_enabled"]) + self.assertEqual(False, single_workbook.data_acceleration_config["accelerate_now"]) + + def test_update_missing_id(self) -> None: + single_workbook = TSC.WorkbookItem("test") self.assertRaises(TSC.MissingRequiredFieldError, self.server.workbooks.update, single_workbook) - def test_update_copy_fields(self): - with open(POPULATE_CONNECTIONS_XML, 'rb') as f: - connection_xml = f.read().decode('utf-8') - with open(UPDATE_XML, 'rb') as f: - update_xml = f.read().decode('utf-8') + def test_update_copy_fields(self) -> None: + with open(POPULATE_CONNECTIONS_XML, "rb") as f: + connection_xml = f.read().decode("utf-8") + with open(UPDATE_XML, "rb") as f: + update_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/1f951daf-4061-451a-9df1-69a8062664f2/connections', text=connection_xml) - m.put(self.baseurl + '/1f951daf-4061-451a-9df1-69a8062664f2', text=update_xml) - single_workbook = TSC.WorkbookItem('1d0304cd-3796-429f-b815-7258370b9b74') - single_workbook._id = '1f951daf-4061-451a-9df1-69a8062664f2' + m.get(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/connections", text=connection_xml) + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=update_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74") + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" self.server.workbooks.populate_connections(single_workbook) updated_workbook = self.server.workbooks.update(single_workbook) @@ -233,195 +238,202 @@ def test_update_copy_fields(self): self.assertEqual(single_workbook._initial_tags, updated_workbook._initial_tags) self.assertEqual(single_workbook._preview_image, updated_workbook._preview_image) - def test_update_tags(self): - with open(ADD_TAGS_XML, 'rb') as f: - add_tags_xml = f.read().decode('utf-8') - with open(UPDATE_XML, 'rb') as f: - update_xml = f.read().decode('utf-8') - with requests_mock.mock() as m: - m.put(self.baseurl + '/1f951daf-4061-451a-9df1-69a8062664f2/tags', text=add_tags_xml) - m.delete(self.baseurl + '/1f951daf-4061-451a-9df1-69a8062664f2/tags/b', status_code=204) - m.delete(self.baseurl + '/1f951daf-4061-451a-9df1-69a8062664f2/tags/d', status_code=204) - m.put(self.baseurl + '/1f951daf-4061-451a-9df1-69a8062664f2', text=update_xml) - single_workbook = TSC.WorkbookItem('1d0304cd-3796-429f-b815-7258370b9b74') - single_workbook._id = '1f951daf-4061-451a-9df1-69a8062664f2' - single_workbook._initial_tags.update(['a', 'b', 'c', 'd']) - single_workbook.tags.update(['a', 'c', 'e']) + def test_update_tags(self) -> None: + with open(ADD_TAGS_XML, "rb") as f: + add_tags_xml = f.read().decode("utf-8") + with open(UPDATE_XML, "rb") as f: + update_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/tags", text=add_tags_xml) + m.delete(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/tags/b", status_code=204) + m.delete(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/tags/d", status_code=204) + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=update_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74") + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook._initial_tags.update(["a", "b", "c", "d"]) + single_workbook.tags.update(["a", "c", "e"]) updated_workbook = self.server.workbooks.update(single_workbook) self.assertEqual(single_workbook.tags, updated_workbook.tags) self.assertEqual(single_workbook._initial_tags, updated_workbook._initial_tags) - def test_download(self): + def test_download(self) -> None: with requests_mock.mock() as m: - m.get(self.baseurl + '/1f951daf-4061-451a-9df1-69a8062664f2/content', - headers={'Content-Disposition': 'name="tableau_workbook"; filename="RESTAPISample.twbx"'}) - file_path = self.server.workbooks.download('1f951daf-4061-451a-9df1-69a8062664f2') + m.get( + self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", + headers={"Content-Disposition": 'name="tableau_workbook"; filename="RESTAPISample.twbx"'}, + ) + file_path = self.server.workbooks.download("1f951daf-4061-451a-9df1-69a8062664f2") self.assertTrue(os.path.exists(file_path)) os.remove(file_path) - def test_download_sanitizes_name(self): + def test_download_sanitizes_name(self) -> None: filename = "Name,With,Commas.twbx" disposition = 'name="tableau_workbook"; filename="{}"'.format(filename) with requests_mock.mock() as m: - m.get(self.baseurl + '/1f951daf-4061-451a-9df1-69a8062664f2/content', - headers={'Content-Disposition': disposition}) - file_path = self.server.workbooks.download('1f951daf-4061-451a-9df1-69a8062664f2') + m.get( + self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", + headers={"Content-Disposition": disposition}, + ) + file_path = self.server.workbooks.download("1f951daf-4061-451a-9df1-69a8062664f2") self.assertEqual(os.path.basename(file_path), "NameWithCommas.twbx") self.assertTrue(os.path.exists(file_path)) os.remove(file_path) - def test_download_extract_only(self): + def test_download_extract_only(self) -> None: # Pretend we're 2.5 for 'extract_only' self.server.version = "2.5" self.baseurl = self.server.workbooks.baseurl with requests_mock.mock() as m: - m.get(self.baseurl + '/1f951daf-4061-451a-9df1-69a8062664f2/content?includeExtract=False', - headers={'Content-Disposition': 'name="tableau_workbook"; filename="RESTAPISample.twbx"'}, - complete_qs=True) + m.get( + self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content?includeExtract=False", + headers={"Content-Disposition": 'name="tableau_workbook"; filename="RESTAPISample.twbx"'}, + complete_qs=True, + ) # Technically this shouldn't download a twbx, but we are interested in the qs, not the file - file_path = self.server.workbooks.download('1f951daf-4061-451a-9df1-69a8062664f2', include_extract=False) + file_path = self.server.workbooks.download("1f951daf-4061-451a-9df1-69a8062664f2", include_extract=False) self.assertTrue(os.path.exists(file_path)) os.remove(file_path) - def test_download_missing_id(self): - self.assertRaises(ValueError, self.server.workbooks.download, '') + def test_download_missing_id(self) -> None: + self.assertRaises(ValueError, self.server.workbooks.download, "") - def test_populate_views(self): - with open(POPULATE_VIEWS_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_populate_views(self) -> None: + with open(POPULATE_VIEWS_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=response_xml) - single_workbook = TSC.WorkbookItem('test') - single_workbook._id = '1f951daf-4061-451a-9df1-69a8062664f2' + m.get(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/views", text=response_xml) + single_workbook = TSC.WorkbookItem("test") + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" self.server.workbooks.populate_views(single_workbook) 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('RESTAPISample/sheets/GDPpercapita', views_list[0].content_url) - - self.assertEqual('2c1ab9d7-8d64-4cc6-b495-52e40c60c330', views_list[1].id) - self.assertEqual('Country ranks', views_list[1].name) - self.assertEqual('RESTAPISample/sheets/Countryranks', views_list[1].content_url) - - self.assertEqual('0599c28c-6d82-457e-a453-e52c1bdb00f5', views_list[2].id) - self.assertEqual('Interest rates', views_list[2].name) - self.assertEqual('RESTAPISample/sheets/Interestrates', views_list[2].content_url) - - def test_populate_views_with_usage(self): - with open(POPULATE_VIEWS_USAGE_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?includeUsageStatistics=true', - text=response_xml) - single_workbook = TSC.WorkbookItem('test') - single_workbook._id = '1f951daf-4061-451a-9df1-69a8062664f2' + self.assertEqual("097dbe13-de89-445f-b2c3-02f28bd010c1", views_list[0].id) + self.assertEqual("GDP per capita", views_list[0].name) + self.assertEqual("RESTAPISample/sheets/GDPpercapita", views_list[0].content_url) + + self.assertEqual("2c1ab9d7-8d64-4cc6-b495-52e40c60c330", views_list[1].id) + self.assertEqual("Country ranks", views_list[1].name) + self.assertEqual("RESTAPISample/sheets/Countryranks", views_list[1].content_url) + + self.assertEqual("0599c28c-6d82-457e-a453-e52c1bdb00f5", views_list[2].id) + self.assertEqual("Interest rates", views_list[2].name) + self.assertEqual("RESTAPISample/sheets/Interestrates", views_list[2].content_url) + + def test_populate_views_with_usage(self) -> None: + with open(POPULATE_VIEWS_USAGE_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?includeUsageStatistics=true", + text=response_xml, + ) + single_workbook = TSC.WorkbookItem("test") + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" self.server.workbooks.populate_views(single_workbook, usage=True) views_list = single_workbook.views - self.assertEqual('097dbe13-de89-445f-b2c3-02f28bd010c1', views_list[0].id) + self.assertEqual("097dbe13-de89-445f-b2c3-02f28bd010c1", views_list[0].id) self.assertEqual(2, views_list[0].total_views) - self.assertEqual('2c1ab9d7-8d64-4cc6-b495-52e40c60c330', views_list[1].id) + self.assertEqual("2c1ab9d7-8d64-4cc6-b495-52e40c60c330", views_list[1].id) self.assertEqual(37, views_list[1].total_views) - self.assertEqual('0599c28c-6d82-457e-a453-e52c1bdb00f5', views_list[2].id) + self.assertEqual("0599c28c-6d82-457e-a453-e52c1bdb00f5", views_list[2].id) self.assertEqual(0, views_list[2].total_views) - def test_populate_views_missing_id(self): - single_workbook = TSC.WorkbookItem('test') + def test_populate_views_missing_id(self) -> None: + single_workbook = TSC.WorkbookItem("test") self.assertRaises(TSC.MissingRequiredFieldError, self.server.workbooks.populate_views, single_workbook) - def test_populate_connections(self): - with open(POPULATE_CONNECTIONS_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_populate_connections(self) -> None: + with open(POPULATE_CONNECTIONS_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/connections', text=response_xml) - single_workbook = TSC.WorkbookItem('test') - single_workbook._id = '1f951daf-4061-451a-9df1-69a8062664f2' + m.get(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/connections", text=response_xml) + single_workbook = TSC.WorkbookItem("test") + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" self.server.workbooks.populate_connections(single_workbook) - self.assertEqual('37ca6ced-58d7-4dcf-99dc-f0a85223cbef', single_workbook.connections[0].id) - self.assertEqual('dataengine', single_workbook.connections[0].connection_type) - self.assertEqual('4506225a-0d32-4ab1-82d3-c24e85f7afba', single_workbook.connections[0].datasource_id) - self.assertEqual('World Indicators', single_workbook.connections[0].datasource_name) + self.assertEqual("37ca6ced-58d7-4dcf-99dc-f0a85223cbef", single_workbook.connections[0].id) + self.assertEqual("dataengine", single_workbook.connections[0].connection_type) + self.assertEqual("4506225a-0d32-4ab1-82d3-c24e85f7afba", single_workbook.connections[0].datasource_id) + self.assertEqual("World Indicators", single_workbook.connections[0].datasource_name) - def test_populate_permissions(self): - with open(POPULATE_PERMISSIONS_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_populate_permissions(self) -> None: + with open(POPULATE_PERMISSIONS_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/21778de4-b7b9-44bc-a599-1506a2639ace/permissions', text=response_xml) - single_workbook = TSC.WorkbookItem('test') - single_workbook._id = '21778de4-b7b9-44bc-a599-1506a2639ace' + m.get(self.baseurl + "/21778de4-b7b9-44bc-a599-1506a2639ace/permissions", text=response_xml) + single_workbook = TSC.WorkbookItem("test") + single_workbook._id = "21778de4-b7b9-44bc-a599-1506a2639ace" self.server.workbooks.populate_permissions(single_workbook) permissions = single_workbook.permissions - self.assertEqual(permissions[0].grantee.tag_name, 'group') - self.assertEqual(permissions[0].grantee.id, '5e5e1978-71fa-11e4-87dd-7382f5c437af') - self.assertDictEqual(permissions[0].capabilities, { - TSC.Permission.Capability.WebAuthoring: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Filter: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.AddComment: TSC.Permission.Mode.Allow - }) - - self.assertEqual(permissions[1].grantee.tag_name, 'user') - self.assertEqual(permissions[1].grantee.id, '7c37ee24-c4b1-42b6-a154-eaeab7ee330a') - self.assertDictEqual(permissions[1].capabilities, { - TSC.Permission.Capability.ExportImage: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ShareView: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Deny, - TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Deny - }) - - def test_add_permissions(self): - with open(UPDATE_PERMISSIONS, 'rb') as f: - response_xml = f.read().decode('utf-8') - - single_workbook = TSC.WorkbookItem('test') - single_workbook._id = '21778de4-b7b9-44bc-a599-1506a2639ace' + self.assertEqual(permissions[0].grantee.tag_name, "group") + self.assertEqual(permissions[0].grantee.id, "5e5e1978-71fa-11e4-87dd-7382f5c437af") + self.assertDictEqual( + permissions[0].capabilities, + { + TSC.Permission.Capability.WebAuthoring: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Filter: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.AddComment: TSC.Permission.Mode.Allow, + }, + ) + + self.assertEqual(permissions[1].grantee.tag_name, "user") + self.assertEqual(permissions[1].grantee.id, "7c37ee24-c4b1-42b6-a154-eaeab7ee330a") + self.assertDictEqual( + permissions[1].capabilities, + { + TSC.Permission.Capability.ExportImage: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ShareView: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Deny, + }, + ) + + def test_add_permissions(self) -> None: + with open(UPDATE_PERMISSIONS, "rb") as f: + response_xml = f.read().decode("utf-8") + + single_workbook = TSC.WorkbookItem("test") + single_workbook._id = "21778de4-b7b9-44bc-a599-1506a2639ace" bob = UserItem.as_reference("7c37ee24-c4b1-42b6-a154-eaeab7ee330a") group_of_people = GroupItem.as_reference("5e5e1978-71fa-11e4-87dd-7382f5c437af") - new_permissions = [ - PermissionsRule(bob, {'Write': 'Allow'}), - PermissionsRule(group_of_people, {'Read': 'Deny'}) - ] + new_permissions = [PermissionsRule(bob, {"Write": "Allow"}), PermissionsRule(group_of_people, {"Read": "Deny"})] with requests_mock.mock() as m: m.put(self.baseurl + "/21778de4-b7b9-44bc-a599-1506a2639ace/permissions", text=response_xml) permissions = self.server.workbooks.update_permissions(single_workbook, new_permissions) - self.assertEqual(permissions[0].grantee.tag_name, 'group') - self.assertEqual(permissions[0].grantee.id, '5e5e1978-71fa-11e4-87dd-7382f5c437af') - self.assertDictEqual(permissions[0].capabilities, { - TSC.Permission.Capability.Read: TSC.Permission.Mode.Deny - }) - - self.assertEqual(permissions[1].grantee.tag_name, 'user') - self.assertEqual(permissions[1].grantee.id, '7c37ee24-c4b1-42b6-a154-eaeab7ee330a') - self.assertDictEqual(permissions[1].capabilities, { - TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow - }) - - def test_populate_connections_missing_id(self): - single_workbook = TSC.WorkbookItem('test') - self.assertRaises(TSC.MissingRequiredFieldError, - self.server.workbooks.populate_connections, - single_workbook) - - def test_populate_pdf(self): + self.assertEqual(permissions[0].grantee.tag_name, "group") + self.assertEqual(permissions[0].grantee.id, "5e5e1978-71fa-11e4-87dd-7382f5c437af") + self.assertDictEqual(permissions[0].capabilities, {TSC.Permission.Capability.Read: TSC.Permission.Mode.Deny}) + + self.assertEqual(permissions[1].grantee.tag_name, "user") + self.assertEqual(permissions[1].grantee.id, "7c37ee24-c4b1-42b6-a154-eaeab7ee330a") + self.assertDictEqual(permissions[1].capabilities, {TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow}) + + def test_populate_connections_missing_id(self) -> None: + single_workbook = TSC.WorkbookItem("test") + self.assertRaises(TSC.MissingRequiredFieldError, self.server.workbooks.populate_connections, single_workbook) + + def test_populate_pdf(self) -> None: self.server.version = "3.4" self.baseurl = self.server.workbooks.baseurl with open(POPULATE_PDF, "rb") as f: response = f.read() with requests_mock.mock() as m: - m.get(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/pdf?type=a5&orientation=landscape", - content=response) - single_workbook = TSC.WorkbookItem('test') - single_workbook._id = '1f951daf-4061-451a-9df1-69a8062664f2' + m.get( + self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/pdf?type=a5&orientation=landscape", + content=response, + ) + single_workbook = TSC.WorkbookItem("test") + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" type = TSC.PDFRequestOptions.PageType.A5 orientation = TSC.PDFRequestOptions.Orientation.Landscape @@ -430,307 +442,442 @@ def test_populate_pdf(self): self.server.workbooks.populate_pdf(single_workbook, req_option) self.assertEqual(response, single_workbook.pdf) - def test_populate_preview_image(self): - with open(POPULATE_PREVIEW_IMAGE, 'rb') as f: + def test_populate_powerpoint(self) -> None: + self.server.version = "3.8" + self.baseurl = self.server.workbooks.baseurl + with open(POPULATE_POWERPOINT, "rb") as f: response = f.read() with requests_mock.mock() as m: - m.get(self.baseurl + '/1f951daf-4061-451a-9df1-69a8062664f2/previewImage', content=response) - single_workbook = TSC.WorkbookItem('test') - single_workbook._id = '1f951daf-4061-451a-9df1-69a8062664f2' + m.get( + self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/powerpoint", + content=response, + ) + single_workbook = TSC.WorkbookItem("test") + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + + self.server.workbooks.populate_powerpoint(single_workbook) + self.assertEqual(response, single_workbook.powerpoint) + + def test_populate_preview_image(self) -> None: + with open(POPULATE_PREVIEW_IMAGE, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/previewImage", content=response) + single_workbook = TSC.WorkbookItem("test") + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" self.server.workbooks.populate_preview_image(single_workbook) self.assertEqual(response, single_workbook.preview_image) - def test_populate_preview_image_missing_id(self): - single_workbook = TSC.WorkbookItem('test') - self.assertRaises(TSC.MissingRequiredFieldError, - self.server.workbooks.populate_preview_image, - single_workbook) + def test_populate_preview_image_missing_id(self) -> None: + single_workbook = TSC.WorkbookItem("test") + self.assertRaises(TSC.MissingRequiredFieldError, self.server.workbooks.populate_preview_image, single_workbook) - def test_publish(self): - with open(PUBLISH_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_publish(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') + 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') + 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) + new_workbook = self.server.workbooks.publish(new_workbook, sample_workbook, publish_mode) - self.assertEqual('a8076ca1-e9d8-495e-bae6-c684dbb55836', new_workbook.id) - self.assertEqual('RESTAPISample', new_workbook.name) - self.assertEqual('RESTAPISample_0', new_workbook.content_url) + self.assertEqual("a8076ca1-e9d8-495e-bae6-c684dbb55836", new_workbook.id) + self.assertEqual("RESTAPISample", new_workbook.name) + self.assertEqual("RESTAPISample_0", new_workbook.content_url) self.assertEqual(False, new_workbook.show_tabs) self.assertEqual(1, new_workbook.size) - self.assertEqual('2016-08-18T18:33:24Z', format_datetime(new_workbook.created_at)) - self.assertEqual('2016-08-18T20:31:34Z', format_datetime(new_workbook.updated_at)) - self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', new_workbook.project_id) - self.assertEqual('default', new_workbook.project_name) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', new_workbook.owner_id) - 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) - - def test_publish_a_packaged_file_object(self): - with open(PUBLISH_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + self.assertEqual("2016-08-18T18:33:24Z", format_datetime(new_workbook.created_at)) + self.assertEqual("2016-08-18T20:31:34Z", format_datetime(new_workbook.updated_at)) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", new_workbook.project_id) + self.assertEqual("default", new_workbook.project_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", new_workbook.owner_id) + 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) + + def test_publish_a_packaged_file_object(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') + 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') + sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") - with open(sample_workbook, 'rb') as fp: + with open(sample_workbook, "rb") as fp: publish_mode = self.server.PublishMode.CreateNew - new_workbook = self.server.workbooks.publish(new_workbook, - fp, - publish_mode) + new_workbook = self.server.workbooks.publish(new_workbook, fp, publish_mode) - self.assertEqual('a8076ca1-e9d8-495e-bae6-c684dbb55836', new_workbook.id) - self.assertEqual('RESTAPISample', new_workbook.name) - self.assertEqual('RESTAPISample_0', new_workbook.content_url) + self.assertEqual("a8076ca1-e9d8-495e-bae6-c684dbb55836", new_workbook.id) + self.assertEqual("RESTAPISample", new_workbook.name) + self.assertEqual("RESTAPISample_0", new_workbook.content_url) self.assertEqual(False, new_workbook.show_tabs) self.assertEqual(1, new_workbook.size) - self.assertEqual('2016-08-18T18:33:24Z', format_datetime(new_workbook.created_at)) - self.assertEqual('2016-08-18T20:31:34Z', format_datetime(new_workbook.updated_at)) - self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', new_workbook.project_id) - self.assertEqual('default', new_workbook.project_name) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', new_workbook.owner_id) - 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) - - def test_publish_non_packeged_file_object(self): - - with open(PUBLISH_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + self.assertEqual("2016-08-18T18:33:24Z", format_datetime(new_workbook.created_at)) + self.assertEqual("2016-08-18T20:31:34Z", format_datetime(new_workbook.updated_at)) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", new_workbook.project_id) + self.assertEqual("default", new_workbook.project_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", new_workbook.owner_id) + 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) + + def test_publish_non_packeged_file_object(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') + new_workbook = TSC.WorkbookItem( + name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" + ) - sample_workbook = os.path.join(TEST_ASSET_DIR, 'RESTAPISample.twb') + sample_workbook = os.path.join(TEST_ASSET_DIR, "RESTAPISample.twb") - with open(sample_workbook, 'rb') as fp: + with open(sample_workbook, "rb") as fp: publish_mode = self.server.PublishMode.CreateNew - new_workbook = self.server.workbooks.publish(new_workbook, - fp, - publish_mode) + new_workbook = self.server.workbooks.publish(new_workbook, fp, publish_mode) - self.assertEqual('a8076ca1-e9d8-495e-bae6-c684dbb55836', new_workbook.id) - self.assertEqual('RESTAPISample', new_workbook.name) - self.assertEqual('RESTAPISample_0', new_workbook.content_url) + self.assertEqual("a8076ca1-e9d8-495e-bae6-c684dbb55836", new_workbook.id) + self.assertEqual("RESTAPISample", new_workbook.name) + self.assertEqual("RESTAPISample_0", new_workbook.content_url) self.assertEqual(False, new_workbook.show_tabs) self.assertEqual(1, new_workbook.size) - self.assertEqual('2016-08-18T18:33:24Z', format_datetime(new_workbook.created_at)) - self.assertEqual('2016-08-18T20:31:34Z', format_datetime(new_workbook.updated_at)) - self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', new_workbook.project_id) - self.assertEqual('default', new_workbook.project_name) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', new_workbook.owner_id) - 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) - - def test_publish_with_hidden_view(self): - with open(PUBLISH_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + self.assertEqual("2016-08-18T18:33:24Z", format_datetime(new_workbook.created_at)) + self.assertEqual("2016-08-18T20:31:34Z", format_datetime(new_workbook.updated_at)) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", new_workbook.project_id) + self.assertEqual("default", new_workbook.project_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", new_workbook.owner_id) + 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) + + def test_publish_path_object(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') + 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') + sample_workbook = Path(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']) + new_workbook = self.server.workbooks.publish(new_workbook, sample_workbook, publish_mode) + + self.assertEqual("a8076ca1-e9d8-495e-bae6-c684dbb55836", new_workbook.id) + self.assertEqual("RESTAPISample", new_workbook.name) + self.assertEqual("RESTAPISample_0", new_workbook.content_url) + self.assertEqual(False, new_workbook.show_tabs) + self.assertEqual(1, new_workbook.size) + self.assertEqual("2016-08-18T18:33:24Z", format_datetime(new_workbook.created_at)) + self.assertEqual("2016-08-18T20:31:34Z", format_datetime(new_workbook.updated_at)) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", new_workbook.project_id) + self.assertEqual("default", new_workbook.project_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", new_workbook.owner_id) + 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) + + def test_publish_with_hidden_views_on_workbook(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.hidden_views = ["GDP per capita"] + new_workbook = self.server.workbooks.publish(new_workbook, sample_workbook, publish_mode) + 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)) + + # 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)) + self.assertTrue(re.search(rb"<\/views>", request_body)) + self.assertTrue(re.search(rb"<\/views>", request_body)) - def test_publish_with_query_params(self): - with open(PUBLISH_ASYNC_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_publish_with_query_params(self) -> None: + with open(PUBLISH_ASYNC_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') + 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') + sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") publish_mode = self.server.PublishMode.CreateNew - self.server.workbooks.publish(new_workbook, sample_workbook, publish_mode, - as_job=True, skip_connection_check=True) + self.server.workbooks.publish( + new_workbook, sample_workbook, publish_mode, as_job=True, skip_connection_check=True + ) request_query_params = m._adapter.request_history[0].qs - self.assertTrue('asjob' in request_query_params) - self.assertTrue(request_query_params['asjob']) - self.assertTrue('skipconnectioncheck' in request_query_params) - self.assertTrue(request_query_params['skipconnectioncheck']) + self.assertTrue("asjob" in request_query_params) + self.assertTrue(request_query_params["asjob"]) + self.assertTrue("skipconnectioncheck" in request_query_params) + self.assertTrue(request_query_params["skipconnectioncheck"]) - def test_publish_async(self): - self.server.version = '3.0' + def test_publish_async(self) -> None: + self.server.version = "3.0" baseurl = self.server.workbooks.baseurl - with open(PUBLISH_ASYNC_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(PUBLISH_ASYNC_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(baseurl, text=response_xml) - new_workbook = TSC.WorkbookItem(name='Sample', - show_tabs=False, - project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + 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') + sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") publish_mode = self.server.PublishMode.CreateNew - new_job = self.server.workbooks.publish(new_workbook, - sample_workbook, - publish_mode, - as_job=True) + new_job = self.server.workbooks.publish(new_workbook, sample_workbook, publish_mode, as_job=True) - self.assertEqual('7c3d599e-949f-44c3-94a1-f30ba85757e4', new_job.id) - self.assertEqual('PublishWorkbook', new_job.type) - self.assertEqual('0', new_job.progress) - self.assertEqual('2018-06-29T23:22:32Z', format_datetime(new_job.created_at)) + self.assertEqual("7c3d599e-949f-44c3-94a1-f30ba85757e4", new_job.id) + self.assertEqual("PublishWorkbook", new_job.type) + self.assertEqual("0", new_job.progress) + self.assertEqual("2018-06-29T23:22:32Z", format_datetime(new_job.created_at)) self.assertEqual(1, new_job.finish_code) - def test_publish_invalid_file(self): - new_workbook = TSC.WorkbookItem('test', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') - self.assertRaises(IOError, self.server.workbooks.publish, new_workbook, '.', - self.server.PublishMode.CreateNew) + def test_publish_invalid_file(self) -> None: + new_workbook = TSC.WorkbookItem("test", "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") + self.assertRaises(IOError, self.server.workbooks.publish, new_workbook, ".", self.server.PublishMode.CreateNew) - def test_publish_invalid_file_type(self): - new_workbook = TSC.WorkbookItem('test', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') - self.assertRaises(ValueError, self.server.workbooks.publish, - new_workbook, os.path.join(TEST_ASSET_DIR, 'SampleDS.tds'), - self.server.PublishMode.CreateNew) + def test_publish_invalid_file_type(self) -> None: + new_workbook = TSC.WorkbookItem("test", "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") + self.assertRaises( + ValueError, + self.server.workbooks.publish, + new_workbook, + os.path.join(TEST_ASSET_DIR, "SampleDS.tds"), + self.server.PublishMode.CreateNew, + ) - def test_publish_unnamed_file_object(self): - new_workbook = TSC.WorkbookItem('test') + def test_publish_unnamed_file_object(self) -> None: + new_workbook = TSC.WorkbookItem("test") - with open(os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx')) as f: + with open(os.path.join(TEST_ASSET_DIR, "SampleWB.twbx"), "rb") as f: - self.assertRaises(ValueError, self.server.workbooks.publish, - new_workbook, f, self.server.PublishMode.CreateNew - ) + self.assertRaises( + ValueError, self.server.workbooks.publish, new_workbook, f, self.server.PublishMode.CreateNew + ) - def test_publish_file_object_of_unknown_type_raises_exception(self): - new_workbook = TSC.WorkbookItem('test') + def test_publish_non_bytes_file_object(self) -> None: + new_workbook = TSC.WorkbookItem("test") + + with open(os.path.join(TEST_ASSET_DIR, "SampleWB.twbx")) as f: + + self.assertRaises( + TypeError, self.server.workbooks.publish, new_workbook, f, self.server.PublishMode.CreateNew + ) + + def test_publish_file_object_of_unknown_type_raises_exception(self) -> None: + new_workbook = TSC.WorkbookItem("test") with BytesIO() as file_object: - file_object.write(bytes.fromhex('89504E470D0A1A0A')) + file_object.write(bytes.fromhex("89504E470D0A1A0A")) file_object.seek(0) - self.assertRaises(ValueError, self.server.workbooks.publish, new_workbook, - file_object, self.server.PublishMode.CreateNew) - - def test_publish_multi_connection(self): - new_workbook = TSC.WorkbookItem(name='Sample', show_tabs=False, - project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + self.assertRaises( + ValueError, self.server.workbooks.publish, new_workbook, file_object, self.server.PublishMode.CreateNew + ) + + def test_publish_multi_connection(self) -> None: + new_workbook = TSC.WorkbookItem( + name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" + ) connection1 = TSC.ConnectionItem() - connection1.server_address = 'mysql.test.com' - connection1.connection_credentials = TSC.ConnectionCredentials('test', 'secret', True) + connection1.server_address = "mysql.test.com" + connection1.connection_credentials = TSC.ConnectionCredentials("test", "secret", True) connection2 = TSC.ConnectionItem() - connection2.server_address = 'pgsql.test.com' - connection2.connection_credentials = TSC.ConnectionCredentials('test', 'secret', True) + connection2.server_address = "pgsql.test.com" + connection2.connection_credentials = TSC.ConnectionCredentials("test", "secret", True) response = RequestFactory.Workbook._generate_xml(new_workbook, connections=[connection1, connection2]) # Can't use ConnectionItem parser due to xml namespace problems - connection_results = ET.fromstring(response).findall('.//connection') + connection_results = fromstring(response).findall(".//connection") - self.assertEqual(connection_results[0].get('serverAddress', None), 'mysql.test.com') - self.assertEqual(connection_results[0].find('connectionCredentials').get('name', None), 'test') - self.assertEqual(connection_results[1].get('serverAddress', None), 'pgsql.test.com') - self.assertEqual(connection_results[1].find('connectionCredentials').get('password', None), 'secret') + self.assertEqual(connection_results[0].get("serverAddress", None), "mysql.test.com") + self.assertEqual(connection_results[0].find("connectionCredentials").get("name", None), "test") # type: ignore[union-attr] + 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): - new_workbook = TSC.WorkbookItem(name='Sample', show_tabs=False, - project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') - connection_creds = TSC.ConnectionCredentials('test', 'secret', True) + 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 = ET.fromstring(response).findall('.//connectionCredentials') + 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') + 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_credentials_and_multi_connect_raises_exception(self): - new_workbook = TSC.WorkbookItem(name='Sample', show_tabs=False, - project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + 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) + connection_creds = TSC.ConnectionCredentials("test", "secret", True) connection1 = TSC.ConnectionItem() - connection1.server_address = 'mysql.test.com' - connection1.connection_credentials = TSC.ConnectionCredentials('test', 'secret', True) + 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]) + response = RequestFactory.Workbook._generate_xml( + new_workbook, connection_credentials=connection_creds, connections=[connection1] + ) - def test_synchronous_publish_timeout_error(self): + def test_synchronous_publish_timeout_error(self) -> None: with requests_mock.mock() as m: - m.register_uri('POST', self.baseurl, status_code=504) + m.register_uri("POST", self.baseurl, status_code=504) - new_workbook = TSC.WorkbookItem(project_id='') + new_workbook = TSC.WorkbookItem(project_id="") publish_mode = self.server.PublishMode.CreateNew - self.assertRaisesRegex(InternalServerError, 'Please use asynchronous publishing to avoid timeouts', - self.server.workbooks.publish, new_workbook, asset('SampleWB.twbx'), publish_mode) + self.assertRaisesRegex( + InternalServerError, + "Please use asynchronous publishing to avoid timeouts", + self.server.workbooks.publish, + new_workbook, + asset("SampleWB.twbx"), + publish_mode, + ) - def test_delete_extracts_all(self): + def test_delete_extracts_all(self) -> None: self.server.version = "3.10" self.baseurl = self.server.workbooks.baseurl + + with open(PUBLISH_ASYNC_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/deleteExtract', status_code=200) - self.server.workbooks.delete_extract('3cc6cd06-89ce-4fdc-b935-5294135d6d42') + m.post( + self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/deleteExtract", status_code=200, text=response_xml + ) + self.server.workbooks.delete_extract("3cc6cd06-89ce-4fdc-b935-5294135d6d42") - def test_create_extracts_all(self): + def test_create_extracts_all(self) -> None: self.server.version = "3.10" self.baseurl = self.server.workbooks.baseurl - with open(PUBLISH_ASYNC_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(PUBLISH_ASYNC_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract', - status_code=200, text=response_xml) - self.server.workbooks.create_extract('3cc6cd06-89ce-4fdc-b935-5294135d6d42') + m.post( + self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract", status_code=200, text=response_xml + ) + self.server.workbooks.create_extract("3cc6cd06-89ce-4fdc-b935-5294135d6d42") - def test_create_extracts_one(self): + def test_create_extracts_one(self) -> None: self.server.version = "3.10" self.baseurl = self.server.workbooks.baseurl - datasource = TSC.DatasourceItem('test') - datasource._id = '1f951daf-4061-451a-9df1-69a8062664f2' + datasource = TSC.DatasourceItem("test") + datasource._id = "1f951daf-4061-451a-9df1-69a8062664f2" - with open(PUBLISH_ASYNC_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(PUBLISH_ASYNC_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract', - status_code=200, text=response_xml) - self.server.workbooks.create_extract('3cc6cd06-89ce-4fdc-b935-5294135d6d42', False, datasource) + m.post( + self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract", status_code=200, text=response_xml + ) + self.server.workbooks.create_extract("3cc6cd06-89ce-4fdc-b935-5294135d6d42", False, datasource) + + def test_revisions(self) -> None: + self.baseurl = self.server.workbooks.baseurl + workbook = TSC.WorkbookItem("project", "test") + workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" + + with open(REVISION_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get("{0}/{1}/revisions".format(self.baseurl, workbook.id), text=response_xml) + self.server.workbooks.populate_revisions(workbook) + revisions = workbook.revisions + + self.assertEqual(len(revisions), 3) + self.assertEqual("2016-07-26T20:34:56Z", format_datetime(revisions[0].created_at)) + self.assertEqual("2016-07-27T20:34:56Z", format_datetime(revisions[1].created_at)) + self.assertEqual("2016-07-28T20:34:56Z", format_datetime(revisions[2].created_at)) + + self.assertEqual(False, revisions[0].deleted) + self.assertEqual(False, revisions[0].current) + self.assertEqual(False, revisions[1].deleted) + self.assertEqual(False, revisions[1].current) + self.assertEqual(False, revisions[2].deleted) + self.assertEqual(True, revisions[2].current) + + self.assertEqual("Cassie", revisions[0].user_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", revisions[0].user_id) + self.assertIsNone(revisions[1].user_name) + self.assertIsNone(revisions[1].user_id) + self.assertEqual("Cassie", revisions[2].user_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", revisions[2].user_id) + + def test_delete_revision(self) -> None: + self.baseurl = self.server.workbooks.baseurl + workbook = TSC.WorkbookItem("project", "test") + workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" + + with requests_mock.mock() as m: + m.delete("{0}/{1}/revisions/3".format(self.baseurl, workbook.id)) + self.server.workbooks.delete_revision(workbook.id, "3") + + def test_download_revision(self) -> None: + with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td: + m.get( + self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/revisions/3/content", + headers={"Content-Disposition": 'name="tableau_datasource"; filename="Sample datasource.tds"'}, + ) + file_path = self.server.workbooks.download_revision("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", "3", td) + self.assertTrue(os.path.exists(file_path)) diff --git a/test/test_workbook_model.py b/test/test_workbook_model.py index 69188fa4a..d45899e2d 100644 --- a/test/test_workbook_model.py +++ b/test/test_workbook_model.py @@ -1,4 +1,5 @@ import unittest + import tableauserverclient as TSC From 9fb183d122adb5b7111dbd4ea5dd9573ee4103d0 Mon Sep 17 00:00:00 2001 From: Jac Date: Thu, 2 Jun 2022 21:15:28 -0700 Subject: [PATCH 008/296] Jac/amar kumar yadav 1044 (#1053) * added new permission populate methods Authored-by: Amar Yadav --- tableauserverclient/models/permissions_item.py | 4 ++++ tableauserverclient/models/project_item.py | 8 ++++++++ .../server/endpoint/projects_endpoint.py | 12 ++++++++++++ 3 files changed, 24 insertions(+) diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 71ca56248..fcb758279 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -37,6 +37,9 @@ class Capability: ViewUnderlyingData = "ViewUnderlyingData" WebAuthoring = "WebAuthoring" Write = "Write" + RunExplainData = "RunExplainData" + CreateRefreshMetrics = "CreateRefreshMetrics" + SaveAs = "SaveAs" class Resource: Workbook = "workbook" @@ -45,6 +48,7 @@ class Resource: Table = "table" Database = "database" View = "view" + Lens = "lens" class PermissionsRule(object): diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 177b3e016..a94d135c2 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -31,6 +31,7 @@ def __init__( self._default_workbook_permissions = None self._default_datasource_permissions = None self._default_flow_permissions = None + self._default_lens_permissions = None @property def content_permissions(self): @@ -69,6 +70,13 @@ def default_flow_permissions(self): raise UnpopulatedPropertyError(error) return self._default_flow_permissions() + @property + def default_lens_permissions(self): + if self._default_lens_permissions is None: + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) + return self._default_lens_permissions() + @property def id(self) -> Optional[str]: return self._id diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index b21ba3682..8edf66f39 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -103,6 +103,10 @@ def populate_datasource_default_permissions(self, item): def populate_flow_default_permissions(self, item): self._default_permissions.populate_default_permissions(item, Permission.Resource.Flow) + @api(version="3.4") + def populate_lens_default_permissions(self, item): + self._default_permissions.populate_default_permissions(item, Permission.Resource.Lens) + @api(version="2.1") def update_workbook_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Workbook) @@ -115,6 +119,10 @@ def update_datasource_default_permissions(self, item, rules): def update_flow_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Flow) + @api(version="3.4") + def update_lens_default_permissions(self, item, rules): + return self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Lens) + @api(version="2.1") def delete_workbook_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Permission.Resource.Workbook) @@ -126,3 +134,7 @@ def delete_datasource_default_permissions(self, item, rule): @api(version="3.4") def delete_flow_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Permission.Resource.Flow) + + @api(version="3.4") + def delete_lens_default_permissions(self, item, rule): + self._default_permissions.delete_default_permission(item, rule, Permission.Resource.Lens) From b9a10fd26b75b584e2fa19a7029784005fdd09cf Mon Sep 17 00:00:00 2001 From: Jac Date: Fri, 3 Jun 2022 20:46:42 -0700 Subject: [PATCH 009/296] Revert "Jac/amar kumar yadav 1044 (#1053)" (#1055) This reverts commit 9fb183d122adb5b7111dbd4ea5dd9573ee4103d0. --- tableauserverclient/models/permissions_item.py | 4 ---- tableauserverclient/models/project_item.py | 8 -------- .../server/endpoint/projects_endpoint.py | 12 ------------ 3 files changed, 24 deletions(-) diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index fcb758279..71ca56248 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -37,9 +37,6 @@ class Capability: ViewUnderlyingData = "ViewUnderlyingData" WebAuthoring = "WebAuthoring" Write = "Write" - RunExplainData = "RunExplainData" - CreateRefreshMetrics = "CreateRefreshMetrics" - SaveAs = "SaveAs" class Resource: Workbook = "workbook" @@ -48,7 +45,6 @@ class Resource: Table = "table" Database = "database" View = "view" - Lens = "lens" class PermissionsRule(object): diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index a94d135c2..177b3e016 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -31,7 +31,6 @@ def __init__( self._default_workbook_permissions = None self._default_datasource_permissions = None self._default_flow_permissions = None - self._default_lens_permissions = None @property def content_permissions(self): @@ -70,13 +69,6 @@ def default_flow_permissions(self): raise UnpopulatedPropertyError(error) return self._default_flow_permissions() - @property - def default_lens_permissions(self): - if self._default_lens_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) - return self._default_lens_permissions() - @property def id(self) -> Optional[str]: return self._id diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 8edf66f39..b21ba3682 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -103,10 +103,6 @@ def populate_datasource_default_permissions(self, item): def populate_flow_default_permissions(self, item): self._default_permissions.populate_default_permissions(item, Permission.Resource.Flow) - @api(version="3.4") - def populate_lens_default_permissions(self, item): - self._default_permissions.populate_default_permissions(item, Permission.Resource.Lens) - @api(version="2.1") def update_workbook_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Workbook) @@ -119,10 +115,6 @@ def update_datasource_default_permissions(self, item, rules): def update_flow_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Flow) - @api(version="3.4") - def update_lens_default_permissions(self, item, rules): - return self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Lens) - @api(version="2.1") def delete_workbook_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Permission.Resource.Workbook) @@ -134,7 +126,3 @@ def delete_datasource_default_permissions(self, item, rule): @api(version="3.4") def delete_flow_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Permission.Resource.Flow) - - @api(version="3.4") - def delete_lens_default_permissions(self, item, rule): - self._default_permissions.delete_default_permission(item, rule, Permission.Resource.Lens) From 1eeaca8709f548b73d7306a1251322c784e656c8 Mon Sep 17 00:00:00 2001 From: Jac Date: Mon, 6 Jun 2022 14:53:28 -0700 Subject: [PATCH 010/296] Prepare release 0.19 (#1049) * Add new samples and delete redundant ones * Clean up hidden_views by making it an attribute of WorkbookItem * Add type hints for workbook and data source revisions, data alerts, Favorites, Flows, groups, permissions, projects, flow runs, site, subscriptions, Users, webhooks * add get_by_id method and test for schedules * Allow null value for user quota tiers * fix workbook.delete_extract * add publish to pypi action fix xml generation for items * Add Status, ParentProjectId and StartedAt filters for jobs endpoint * make project_id nullable to support "Personal Space" for workbooks * create single Credentials class * Reassign content on user removal * add redaction method to remove passwords when logging requests and responses, which can contain embedded credentials. * remove support for python 3.6 (add python version enforcement in setup.py) * Extract refreshable item IDs from job XML response * Do not eagerly fetch content when a stream was requested * Fix QuerySet slicing logic * add CRUD methods for default permissions * refactor Resource Types and add sample code --- .github/workflows/run-tests.yml | 2 +- .gitignore | 1 - README.md | 2 +- publish.sh | 7 +- pyproject.toml | 18 ++++ samples/add_default_permission.py | 2 +- samples/create_group.py | 2 +- samples/create_project.py | 23 +++- samples/create_schedules.py | 2 +- samples/download_view_image.py | 77 ------------- samples/export.py | 31 ++++-- samples/export_wb.py | 101 ------------------ samples/filter_sort_groups.py | 2 +- samples/filter_sort_projects.py | 2 +- samples/kill_all_jobs.py | 2 +- samples/list.py | 2 +- samples/login.py | 2 +- samples/metadata_query.py | 2 +- samples/move_workbook_projects.py | 2 +- samples/move_workbook_sites.py | 2 +- samples/publish_datasource.py | 2 +- samples/publish_workbook.py | 2 +- samples/query_permissions.py | 2 +- samples/refresh.py | 2 +- samples/refresh_tasks.py | 2 +- samples/set_refresh_schedule.py | 2 +- samples/update_connection.py | 2 +- setup.py | 8 +- tableauserverclient/__init__.py | 4 + tableauserverclient/helpers/__init__.py | 1 + tableauserverclient/helpers/strings.py | 45 ++++++++ tableauserverclient/models/__init__.py | 5 +- tableauserverclient/models/data_alert_item.py | 6 ++ tableauserverclient/models/database_item.py | 6 +- tableauserverclient/models/datasource_item.py | 1 + tableauserverclient/models/flow_item.py | 8 ++ tableauserverclient/models/flow_run_item.py | 5 + tableauserverclient/models/job_item.py | 26 +++++ .../models/permissions_item.py | 16 +-- .../models/personal_access_token_auth.py | 17 --- tableauserverclient/models/project_item.py | 21 +++- tableauserverclient/models/reference_item.py | 5 + tableauserverclient/models/tableau_auth.py | 75 +++++++++---- tableauserverclient/models/tableau_types.py | 31 ++++++ tableauserverclient/models/user_item.py | 6 ++ tableauserverclient/models/view_item.py | 10 +- tableauserverclient/models/workbook_item.py | 2 +- tableauserverclient/server/__init__.py | 4 + .../server/endpoint/databases_endpoint.py | 10 +- .../server/endpoint/datasources_endpoint.py | 2 +- .../endpoint/default_permissions_endpoint.py | 59 +++++----- .../server/endpoint/endpoint.py | 75 +++++++------ .../server/endpoint/permissions_endpoint.py | 12 ++- .../server/endpoint/projects_endpoint.py | 36 ++++--- .../server/endpoint/tasks_endpoint.py | 4 +- .../server/endpoint/users_endpoint.py | 6 +- .../server/endpoint/views_endpoint.py | 12 +-- .../server/endpoint/workbooks_endpoint.py | 3 +- tableauserverclient/server/query.py | 3 +- tableauserverclient/server/request_factory.py | 21 +++- tableauserverclient/server/request_options.py | 3 + test/assets/job_get_by_id_failed_workbook.xml | 9 ++ test/assets/queryset_slicing_page_1.xml | 46 ++++++++ test/assets/queryset_slicing_page_2.xml | 46 ++++++++ test/request_factory/__init__.py | 0 .../test_datasource_requests.py | 15 +++ .../request_factory/test_workbook_requests.py | 55 ++++++++++ test/test_dqw.py | 11 ++ test/test_endpoint.py | 40 +++++++ test/test_job.py | 19 +++- test/test_regression_tests.py | 53 ++++++--- test/test_request_option.py | 51 +++++---- test/test_user.py | 10 ++ test/test_workbook_model.py | 6 -- 74 files changed, 789 insertions(+), 418 deletions(-) create mode 100644 pyproject.toml delete mode 100644 samples/download_view_image.py delete mode 100644 samples/export_wb.py create mode 100644 tableauserverclient/helpers/__init__.py create mode 100644 tableauserverclient/helpers/strings.py delete mode 100644 tableauserverclient/models/personal_access_token_auth.py create mode 100644 tableauserverclient/models/tableau_types.py create mode 100644 test/assets/job_get_by_id_failed_workbook.xml create mode 100644 test/assets/queryset_slicing_page_1.xml create mode 100644 test/assets/queryset_slicing_page_2.xml create mode 100644 test/request_factory/__init__.py create mode 100644 test/request_factory/test_datasource_requests.py create mode 100644 test/request_factory/test_workbook_requests.py create mode 100644 test/test_dqw.py create mode 100644 test/test_endpoint.py diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 9fe99f953..60a209b61 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.6', '3.7', '3.8', '3.9', '3.10'] + python-version: ['3.7', '3.8', '3.9', '3.10'] runs-on: ${{ matrix.os }} diff --git a/.gitignore b/.gitignore index 5efc6b31d..d8caf99a9 100644 --- a/.gitignore +++ b/.gitignore @@ -78,7 +78,6 @@ target/ # poetry poetry.lock -pyproject.toml # celery beat schedule file celerybeat-schedule diff --git a/README.md b/README.md index f14c23230..b21c2665d 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Use the Tableau Server Client (TSC) library to increase your productivity as you * Create users and groups. * Query projects, sites, and more. -This repository contains Python source code for the library and sample files showing how to use it. Python versions 3.6 and up are supported. +This repository contains Python source code for the library and sample files showing how to use it. As of May 2022, Python versions 3.7 and up are supported. To see sample code that works directly with the REST API (in Java, Python, or Postman), visit the [REST API Samples](https://github.com/tableau/rest-api-samples) repo. diff --git a/publish.sh b/publish.sh index 02812c1c3..46d54a1ee 100755 --- a/publish.sh +++ b/publish.sh @@ -1,8 +1,11 @@ #!/usr/bin/env bash +# tag the release version and confirm a clean version number +git tag vxxxx +git describe --tag --dirty --always + set -e rm -rf dist -python3 setup.py sdist -python3 setup.py bdist_wheel +python setup.py sdist bdist_wheel twine upload dist/* diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..1884a6b37 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[build-system] +requires = ["setuptools>=45.0", "versioneer-518", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.black] +line-length = 120 +target-version = ['py37', 'py38', 'py39', 'py310'] + +[tool.mypy] +disable_error_code = [ + 'misc', + 'import' +] +files = [ + "tableauserverclient", + "test" +] +show_error_codes = true diff --git a/samples/add_default_permission.py b/samples/add_default_permission.py index 56d3afdf1..829190359 100644 --- a/samples/add_default_permission.py +++ b/samples/add_default_permission.py @@ -1,6 +1,6 @@ #### # This script demonstrates how to add default permissions using TSC -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. # # In order to demonstrate adding a new default permission, this sample will create # a new project and add a new capability to the new project, for the default "All users" group. diff --git a/samples/create_group.py b/samples/create_group.py index 16016398d..3875ffea5 100644 --- a/samples/create_group.py +++ b/samples/create_group.py @@ -2,7 +2,7 @@ # This script demonstrates how to create a group using the Tableau # Server Client. # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### diff --git a/samples/create_project.py b/samples/create_project.py index 6271f3d93..8b2ec3354 100644 --- a/samples/create_project.py +++ b/samples/create_project.py @@ -4,7 +4,7 @@ # parent_id. # # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### import argparse @@ -21,7 +21,8 @@ def create_project(server, project_item, samples=False): return project_item except TSC.ServerResponseError: print("We have already created this project: %s" % project_item.name) - sys.exit(1) + project_items = server.projects.filter(name=project_item.name) + return project_items[0] def main(): @@ -52,7 +53,8 @@ def main(): logging.basicConfig(level=logging_level) tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) - server = TSC.Server(args.server) + server = TSC.Server(args.server, http_options={"verify": False}) + server.use_server_version() with server.auth.sign_in(tableau_auth): # Use highest Server REST API version available @@ -73,6 +75,21 @@ def main(): # Projects can be updated changed_project = server.projects.update(grand_child_project, samples=True) + server.projects.populate_workbook_default_permissions(changed_project), + server.projects.populate_flow_default_permissions(changed_project), + server.projects.populate_lens_default_permissions(changed_project), # uses same as workbook + server.projects.populate_datasource_default_permissions(changed_project), + server.projects.populate_permissions(changed_project) + # Projects have default permissions set for the object types they contain + print("Permissions from project {}:".format(changed_project.id)) + print(changed_project.permissions) + print( + changed_project.default_workbook_permissions, + changed_project.default_datasource_permissions, + changed_project.default_lens_permissions, + changed_project.default_flow_permissions, + ) + if __name__ == "__main__": main() diff --git a/samples/create_schedules.py b/samples/create_schedules.py index 4fe6db5a4..87b43dbca 100644 --- a/samples/create_schedules.py +++ b/samples/create_schedules.py @@ -2,7 +2,7 @@ # This script demonstrates how to create schedules using the Tableau # Server Client. # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### diff --git a/samples/download_view_image.py b/samples/download_view_image.py deleted file mode 100644 index 3b2fbac1c..000000000 --- a/samples/download_view_image.py +++ /dev/null @@ -1,77 +0,0 @@ -#### -# This script demonstrates how to use the Tableau Server Client -# to download a high resolution image of a view from Tableau Server. -# -# For more information, refer to the documentations on 'Query View Image' -# (https://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm) -# -# To run the script, you must have installed Python 3.6 or later. -#### - -import argparse -import logging - -import tableauserverclient as TSC - - -def main(): - - parser = argparse.ArgumentParser(description="Download image of a specified view.") - # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") - parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, 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 - parser.add_argument("--view-name", "-vn", required=True, help="name of view to download an image of") - parser.add_argument("--filepath", "-f", required=True, help="filepath to save the image returned") - parser.add_argument("--maxage", "-m", required=False, help="max age of the image in the cache in minutes.") - - 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) - - # Step 1: Sign in to server. - tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) - server = TSC.Server(args.server, use_server_version=True) - with server.auth.sign_in(tableau_auth): - # Step 2: Query for the view that we want an image of - req_option = TSC.RequestOptions() - req_option.filter.add( - TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, args.view_name) - ) - all_views, pagination_item = server.views.get(req_option) - if not all_views: - raise LookupError("View with the specified name was not found.") - view_item = all_views[0] - - max_age = args.maxage - if not max_age: - max_age = 1 - - image_req_option = TSC.ImageRequestOptions( - imageresolution=TSC.ImageRequestOptions.Resolution.High, maxage=max_age - ) - server.views.populate_image(view_item, image_req_option) - - with open(args.filepath, "wb") as image_file: - image_file.write(view_item.image) - - print("View image saved to {0}".format(args.filepath)) - - -if __name__ == "__main__": - main() diff --git a/samples/export.py b/samples/export.py index 701f93fee..4c26770b9 100644 --- a/samples/export.py +++ b/samples/export.py @@ -2,7 +2,7 @@ # This script demonstrates how to export a view using the Tableau # Server Client. # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### import argparse @@ -40,10 +40,13 @@ def main(): group.add_argument( "--csv", dest="type", action="store_const", const=("populate_csv", "CSVRequestOptions", "csv", "csv") ) + # other options shown in explore_workbooks: workbook.download, workbook.preview_image + + parser.add_argument("--workbook", action="store_true") parser.add_argument("--file", "-f", help="filename to store the exported data") parser.add_argument("--filter", "-vf", metavar="COLUMN:VALUE", help="View filter to apply to the view") - parser.add_argument("resource_id", help="LUID for the view") + parser.add_argument("resource_id", help="LUID for the view or workbook") args = parser.parse_args() @@ -52,34 +55,46 @@ def main(): 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=True) + server = TSC.Server(args.server, use_server_version=True, http_options={"verify": False}) with server.auth.sign_in(tableau_auth): - views = filter(lambda x: x.id == args.resource_id or x.name == args.resource_id, TSC.Pager(server.views.get)) - view = list(views).pop() # in python 3 filter() returns a filter object + print("Connected") + if args.workbook: + item = server.workbooks.get_by_id(args.resource_id) + else: + item = server.views.get_by_id(args.resource_id) + + if not item: + print("No item found for id {}".format(args.resource_id)) + exit(1) + print("Item found: {}".format(item.name)) # We have a number of different types and functions for each different export type. # We encode that information above in the const=(...) parameter to the add_argument function to make # the code automatically adapt for the type of export the user is doing. # We unroll that information into methods we can call, or objects we can create by using getattr() (populate_func_name, option_factory_name, member_name, extension) = args.type populate = getattr(server.views, populate_func_name) + if args.workbook: + populate = getattr(server.workbooks, populate_func_name) + option_factory = getattr(TSC, option_factory_name) if args.filter: options = option_factory().vf(*args.filter.split(":")) else: options = None + if args.file: filename = args.file else: filename = "out.{}".format(extension) - populate(view, options) + populate(item, options) with open(filename, "wb") as f: if member_name == "csv": - f.writelines(getattr(view, member_name)) + f.writelines(getattr(item, member_name)) else: - f.write(getattr(view, member_name)) + f.write(getattr(item, member_name)) print("saved to " + filename) diff --git a/samples/export_wb.py b/samples/export_wb.py deleted file mode 100644 index 2376ee62b..000000000 --- a/samples/export_wb.py +++ /dev/null @@ -1,101 +0,0 @@ -#### -# This sample uses the PyPDF2 library for combining pdfs together to get the full pdf for all the views in a -# workbook. -# -# You will need to do `pip install PyPDF2` to use this sample. -# -# To run the script, you must have installed Python 3.6 or later. -#### - - -import argparse -import logging -import tempfile -import shutil -import functools -import os.path - -import tableauserverclient as TSC - -try: - import PyPDF2 -except ImportError: - print("Please `pip install PyPDF2` to use this sample") - import sys - - sys.exit(1) - - -def get_views_for_workbook(server, workbook_id): # -> Iterable of views - workbook = server.workbooks.get_by_id(workbook_id) - server.workbooks.populate_views(workbook) - return workbook.views - - -def download_pdf(server, tempdir, view): # -> Filename to downloaded pdf - logging.info("Exporting {}".format(view.id)) - destination_filename = os.path.join(tempdir, view.id) - server.views.populate_pdf(view) - with file(destination_filename, "wb") as f: - f.write(view.pdf) - - return destination_filename - - -def combine_into(dest_pdf, filename): # -> None - dest_pdf.append(filename) - return dest_pdf - - -def cleanup(tempdir): - shutil.rmtree(tempdir) - - -def main(): - parser = argparse.ArgumentParser(description="Export to PDF all of the views in a workbook.") - # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") - parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, 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 - parser.add_argument("--file", "-f", default="out.pdf", help="filename to store the exported data") - parser.add_argument("resource_id", help="LUID for the workbook") - - 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) - - tempdir = tempfile.mkdtemp("tsc") - logging.debug("Saving to tempdir: %s", tempdir) - - try: - tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) - server = TSC.Server(args.server, use_server_version=True) - with server.auth.sign_in(tableau_auth): - get_list = functools.partial(get_views_for_workbook, server) - download = functools.partial(download_pdf, server, tempdir) - - downloaded = (download(x) for x in get_list(args.resource_id)) - output = reduce(combine_into, downloaded, PyPDF2.PdfFileMerger()) - with file(args.file, "wb") as f: - output.write(f) - finally: - cleanup(tempdir) - - -if __name__ == "__main__": - main() diff --git a/samples/filter_sort_groups.py b/samples/filter_sort_groups.py index e4f2c2bee..c63764134 100644 --- a/samples/filter_sort_groups.py +++ b/samples/filter_sort_groups.py @@ -2,7 +2,7 @@ # This script demonstrates how to filter and sort groups using the Tableau # Server Client. # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### diff --git a/samples/filter_sort_projects.py b/samples/filter_sort_projects.py index 628b1c972..bd43cd209 100644 --- a/samples/filter_sort_projects.py +++ b/samples/filter_sort_projects.py @@ -2,7 +2,7 @@ # This script demonstrates how to use the Tableau Server Client # to filter and sort on the name of the projects present on site. # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### import argparse diff --git a/samples/kill_all_jobs.py b/samples/kill_all_jobs.py index 02f19d976..1a833f938 100644 --- a/samples/kill_all_jobs.py +++ b/samples/kill_all_jobs.py @@ -1,7 +1,7 @@ #### # This script demonstrates how to kill all of the running jobs # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### import argparse diff --git a/samples/list.py b/samples/list.py index db0b7c790..814c1b9ca 100644 --- a/samples/list.py +++ b/samples/list.py @@ -1,7 +1,7 @@ #### # This script demonstrates how to list all of the workbooks or datasources # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### import argparse diff --git a/samples/login.py b/samples/login.py index c459b9370..f0ff9ad49 100644 --- a/samples/login.py +++ b/samples/login.py @@ -1,7 +1,7 @@ #### # This script demonstrates how to log in to Tableau Server Client. # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### import argparse diff --git a/samples/metadata_query.py b/samples/metadata_query.py index 65df9ddb0..26f8f94fa 100644 --- a/samples/metadata_query.py +++ b/samples/metadata_query.py @@ -1,7 +1,7 @@ #### # This script demonstrates how to use the metadata API to query information on a published data source # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### import argparse diff --git a/samples/move_workbook_projects.py b/samples/move_workbook_projects.py index 22465925f..884c7eab1 100644 --- a/samples/move_workbook_projects.py +++ b/samples/move_workbook_projects.py @@ -4,7 +4,7 @@ # a workbook that matches a given name and update it to be in # the desired project. # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### import argparse diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index c473712e4..a2d11bdfe 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -4,7 +4,7 @@ # a workbook that matches a given name, download the workbook, # and then publish it to the destination site. # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### import argparse diff --git a/samples/publish_datasource.py b/samples/publish_datasource.py index ad929fd99..eecbe7088 100644 --- a/samples/publish_datasource.py +++ b/samples/publish_datasource.py @@ -15,7 +15,7 @@ # more information on personal access tokens, refer to the documentations: # (https://help.tableau.com/current/server/en-us/security_personal_access_tokens.htm) # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### import argparse diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index c553eda0b..3cc27c582 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -11,7 +11,7 @@ # For more information, refer to the documentations on 'Publish Workbook' # (https://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm) # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### import argparse diff --git a/samples/query_permissions.py b/samples/query_permissions.py index c0d1c3afa..0c285d4c3 100644 --- a/samples/query_permissions.py +++ b/samples/query_permissions.py @@ -1,6 +1,6 @@ #### # This script demonstrates how to query for permissions using TSC -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. # # Example usage: 'python query_permissions.py -s https://10ax.online.tableau.com --site # devSite123 -u tabby@tableau.com workbook b4065286-80f0-11ea-af1b-cb7191f48e45' diff --git a/samples/refresh.py b/samples/refresh.py index 18a7f36e2..f90441224 100644 --- a/samples/refresh.py +++ b/samples/refresh.py @@ -1,7 +1,7 @@ #### # This script demonstrates how to use trigger a refresh on a datasource or workbook # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### import argparse diff --git a/samples/refresh_tasks.py b/samples/refresh_tasks.py index 6ef781544..2bfc85621 100644 --- a/samples/refresh_tasks.py +++ b/samples/refresh_tasks.py @@ -2,7 +2,7 @@ # This script demonstrates how to use the Tableau Server Client # to query extract refresh tasks and run them as needed. # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### import argparse diff --git a/samples/set_refresh_schedule.py b/samples/set_refresh_schedule.py index decdc223f..9b3dbc236 100644 --- a/samples/set_refresh_schedule.py +++ b/samples/set_refresh_schedule.py @@ -2,7 +2,7 @@ # This script demonstrates how to set the refresh schedule for # a workbook or datasource. # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### diff --git a/samples/update_connection.py b/samples/update_connection.py index 44f8ec6c0..e27b4477f 100644 --- a/samples/update_connection.py +++ b/samples/update_connection.py @@ -1,7 +1,7 @@ #### # This script demonstrates how to update a connections credentials on a server to embed the credentials # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### import argparse diff --git a/setup.py b/setup.py index ae19dcd26..24d35250c 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ # This makes work easier for offline installs or low bandwidth machines needs_pytest = {'pytest', 'test', 'ptr'}.intersection(sys.argv) pytest_runner = ['pytest-runner'] if needs_pytest else [] -test_requirements = ['black', 'mock', 'pytest', 'requests-mock>=1.0,<2.0', 'mypy==0.910'] +test_requirements = ['black', 'mock', 'pytest', 'requests-mock>=1.0,<2.0', 'mypy>=0.920'] setup( name='tableauserverclient', @@ -25,7 +25,10 @@ author_email='github@tableau.com', url='https://github.com/tableau/server-client-python', package_data={'tableauserverclient':['py.typed']}, - packages=['tableauserverclient', 'tableauserverclient.models', 'tableauserverclient.server', + packages=['tableauserverclient', + 'tableauserverclient.helpers', + 'tableauserverclient.models', + 'tableauserverclient.server', 'tableauserverclient.server.endpoint'], license='MIT', description='A Python module for working with the Tableau Server REST API.', @@ -37,6 +40,7 @@ 'defusedxml>=0.7.1', 'requests>=2.11,<3.0', ], + python_requires='>3.7.0', tests_require=test_requirements, extras_require={ 'test': test_requirements diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 897c69fb0..592551b4e 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -37,6 +37,9 @@ FlowRunItem, RevisionItem, MetricItem, + TableauItem, + Resource, + plural_type, ) from .namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE from .server import ( @@ -52,6 +55,7 @@ NotSignedInError, Pager, ) +from .helpers import * __version__ = get_versions()["version"] __VERSION__ = __version__ diff --git a/tableauserverclient/helpers/__init__.py b/tableauserverclient/helpers/__init__.py new file mode 100644 index 000000000..7daf0d490 --- /dev/null +++ b/tableauserverclient/helpers/__init__.py @@ -0,0 +1 @@ +from .strings import * diff --git a/tableauserverclient/helpers/strings.py b/tableauserverclient/helpers/strings.py new file mode 100644 index 000000000..e51a6611a --- /dev/null +++ b/tableauserverclient/helpers/strings.py @@ -0,0 +1,45 @@ +from defusedxml.ElementTree import fromstring, tostring +from functools import singledispatch +from typing import TypeVar + + +# the redact method can handle either strings or bytes, but it can't mix them. +# Generic type so we can write the actual logic once, then use singledispatch to +# create the replacement text with the correct type +T = TypeVar("T", str, bytes) + + +# usage: _redact_any_type("") +# -> b" +def _redact_any_type(xml: T, sensitive_word: T, replacement: T, encoding=None) -> T: + try: + root = fromstring(xml) + matches = root.findall(".//*[@password]") + for item in matches: + item.attrib["password"] = "********" + matches = root.findall(".//password") + for item in matches: + item.text = "********" + # tostring returns bytes unless an encoding value is passed + return tostring(root, encoding=encoding) + except Exception: + # something about the xml handling failed. Just cut off the text at the first occurrence of "password" + location = xml.find(sensitive_word) + return xml[:location] + replacement + + +@singledispatch +def redact_xml(content): + # this will only be called if it didn't get directed to the str or bytes overloads + raise TypeError("Redaction only works on xml saved as str or bytes") + + +@redact_xml.register +def _(xml: str) -> str: + out = _redact_any_type(xml, "password", "...[redacted]", encoding="unicode") + return out + + +@redact_xml.register # type: ignore[no-redef] +def _(xml: bytes) -> bytes: + return _redact_any_type(bytearray(xml), b"password", b"..[redacted]") diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index f72878366..58e5ed6d1 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -22,8 +22,6 @@ from .metric_item import MetricItem from .pagination_item import PaginationItem from .permissions_item import PermissionsRule, Permission -from .personal_access_token_auth import PersonalAccessTokenAuth -from .personal_access_token_auth import PersonalAccessTokenAuth from .project_item import ProjectItem from .revision_item import RevisionItem from .schedule_item import ScheduleItem @@ -31,7 +29,8 @@ from .site_item import SiteItem from .subscription_item import SubscriptionItem from .table_item import TableItem -from .tableau_auth import TableauAuth +from .tableau_auth import Credentials, TableauAuth, PersonalAccessTokenAuth +from .tableau_types import Resource, TableauItem, plural_type from .target import Target from .task_item import TaskItem from .user_item import UserItem diff --git a/tableauserverclient/models/data_alert_item.py b/tableauserverclient/models/data_alert_item.py index 1455743cd..3882d14eb 100644 --- a/tableauserverclient/models/data_alert_item.py +++ b/tableauserverclient/models/data_alert_item.py @@ -12,6 +12,12 @@ from datetime import datetime +from typing import List, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from datetime import datetime + + class DataAlertItem(object): class Frequency: Once = "Once" diff --git a/tableauserverclient/models/database_item.py b/tableauserverclient/models/database_item.py index 862a51a11..3d5a00a1a 100644 --- a/tableauserverclient/models/database_item.py +++ b/tableauserverclient/models/database_item.py @@ -1,3 +1,5 @@ +import logging + from defusedxml.ElementTree import fromstring from .exceptions import UnpopulatedPropertyError @@ -242,11 +244,13 @@ def _set_tables(self, tables): self._tables = tables def _set_default_permissions(self, permissions, content_type): + attr = "_default_{content}_permissions".format(content=content_type) setattr( self, - "_default_{content}_permissions".format(content=content_type), + attr, permissions, ) + logging.getLogger().debug({"type": attr, "value": getattr(self, attr)}) def _set_data_quality_warnings(self, dqw): self._data_quality_warnings = dqw diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index c7823918f..37ec1449a 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -24,6 +24,7 @@ if TYPE_CHECKING: from .permissions_item import PermissionsRule from .connection_item import ConnectionItem + from .revision_item import RevisionItem import datetime diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index 96a99c943..d957f5e14 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -12,6 +12,14 @@ if TYPE_CHECKING: import datetime +from typing import List, Optional, TYPE_CHECKING, Set + +if TYPE_CHECKING: + import datetime + from .connection_item import ConnectionItem + from .permissions_item import Permission + from .dqw_item import DQWItem + class FlowItem(object): def __init__(self, project_id: str, name: Optional[str] = None) -> None: diff --git a/tableauserverclient/models/flow_run_item.py b/tableauserverclient/models/flow_run_item.py index f6ce3d0d5..ce859a65b 100644 --- a/tableauserverclient/models/flow_run_item.py +++ b/tableauserverclient/models/flow_run_item.py @@ -5,6 +5,11 @@ from ..datetime_helpers import parse_datetime +if TYPE_CHECKING: + from datetime import datetime + +from typing import Dict, List, Optional, Type, TYPE_CHECKING + if TYPE_CHECKING: from datetime import datetime diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index e05c42e22..39562cd45 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -31,6 +31,8 @@ def __init__( finish_code: int = 0, notes: Optional[List[str]] = None, mode: Optional[str] = None, + workbook_id: Optional[str] = None, + datasource_id: Optional[str] = None, flow_run: Optional[FlowRunItem] = None, ): self._id = id_ @@ -42,6 +44,8 @@ def __init__( self._finish_code = finish_code self._notes: List[str] = notes or [] self._mode = mode + self._workbook_id = workbook_id + self._datasource_id = datasource_id self._flow_run = flow_run @property @@ -85,6 +89,22 @@ def mode(self, value: str) -> None: # check for valid data here self._mode = value + @property + def workbook_id(self) -> Optional[str]: + return self._workbook_id + + @workbook_id.setter + def workbook_id(self, value: Optional[str]) -> None: + self._workbook_id = value + + @property + def datasource_id(self) -> Optional[str]: + return self._datasource_id + + @datasource_id.setter + def datasource_id(self, value: Optional[str]) -> None: + self._datasource_id = value + @property def flow_run(self): return self._flow_run @@ -119,6 +139,10 @@ def _parse_element(cls, element, ns): finish_code = int(element.get("finishCode", -1)) notes = [note.text for note in element.findall(".//t:notes", namespaces=ns)] or None mode = element.get("mode", None) + workbook = element.find(".//t:workbook[@id]", namespaces=ns) + workbook_id = workbook.get("id") if workbook is not None else None + datasource = element.find(".//t:datasource[@id]", namespaces=ns) + datasource_id = datasource.get("id") if datasource is not None else None flow_run = None for flow_job in element.findall(".//t:runFlowJobType", namespaces=ns): flow_run = FlowRunItem() @@ -136,6 +160,8 @@ def _parse_element(cls, element, ns): finish_code, notes, mode, + workbook_id, + datasource_id, flow_run, ) diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 71ca56248..1c1e9db4d 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -37,14 +37,9 @@ class Capability: ViewUnderlyingData = "ViewUnderlyingData" WebAuthoring = "WebAuthoring" Write = "Write" - - class Resource: - Workbook = "workbook" - Datasource = "datasource" - Flow = "flow" - Table = "table" - Database = "database" - View = "view" + RunExplainData = "RunExplainData" + CreateRefreshMetrics = "CreateRefreshMetrics" + SaveAs = "SaveAs" class PermissionsRule(object): @@ -52,6 +47,11 @@ def __init__(self, grantee: "ResourceReference", capabilities: Dict[str, str]) - self.grantee = grantee self.capabilities = capabilities + def __str__(self): + return "".format(self.grantee, self.capabilities) + + __repr__ = __str__ + @classmethod def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: parsed_response = fromstring(resp) diff --git a/tableauserverclient/models/personal_access_token_auth.py b/tableauserverclient/models/personal_access_token_auth.py deleted file mode 100644 index e1744766d..000000000 --- a/tableauserverclient/models/personal_access_token_auth.py +++ /dev/null @@ -1,17 +0,0 @@ -class PersonalAccessTokenAuth(object): - def __init__(self, token_name, personal_access_token, site_id=None): - self.token_name = token_name - self.personal_access_token = personal_access_token - self.site_id = site_id if site_id is not None else "" - # Personal Access Tokens doesn't support impersonation. - self.user_id_to_impersonate = None - - @property - def credentials(self): - return { - "personalAccessTokenName": self.token_name, - "personalAccessTokenSecret": self.personal_access_token, - } - - def __repr__(self): - return "".format(self.token_name, self.personal_access_token) diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 177b3e016..9237d134e 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -1,11 +1,16 @@ +import logging import xml.etree.ElementTree as ET -from typing import List, Optional from defusedxml.ElementTree import fromstring from .exceptions import UnpopulatedPropertyError from .property_decorators import property_is_enum, property_not_empty +from typing import List, Optional + + +from typing import List, Optional, TYPE_CHECKING + class ProjectItem(object): class ContentPermissions: @@ -31,6 +36,7 @@ def __init__( self._default_workbook_permissions = None self._default_datasource_permissions = None self._default_flow_permissions = None + self._default_lens_permissions = None @property def content_permissions(self): @@ -69,6 +75,13 @@ def default_flow_permissions(self): raise UnpopulatedPropertyError(error) return self._default_flow_permissions() + @property + def default_lens_permissions(self): + if self._default_lens_permissions is None: + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) + return self._default_lens_permissions() + @property def id(self) -> Optional[str]: return self._id @@ -126,11 +139,15 @@ def _set_permissions(self, permissions): self._permissions = permissions def _set_default_permissions(self, permissions, content_type): + attr = "_default_{content}_permissions".format(content=content_type) setattr( self, - "_default_{content}_permissions".format(content=content_type), + attr, permissions, ) + fetch_call = getattr(self, attr) + logging.getLogger().info({"type": attr, "value": fetch_call()}) + return fetch_call() @classmethod def from_response(cls, resp, ns) -> List["ProjectItem"]: diff --git a/tableauserverclient/models/reference_item.py b/tableauserverclient/models/reference_item.py index 48d2ab56a..6fc6b0c22 100644 --- a/tableauserverclient/models/reference_item.py +++ b/tableauserverclient/models/reference_item.py @@ -3,6 +3,11 @@ def __init__(self, id_, tag_name): self.id = id_ self.tag_name = tag_name + def __str__(self): + return "".format(self._id, self._tag_name) + + __repr__ = __str__ + @property def id(self): return self._id diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index e9760cbee..f373a84ab 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -1,41 +1,70 @@ -class TableauAuth(object): +class Credentials: + 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 + 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 __repr__(self): + display = "All Credentials types must have a debug display that does not print secrets" + + +def deprecate_site_attribute(): + import warnings + + warnings.warn( + "TableauAuth(..., site=...) is deprecated, " "please use TableauAuth(..., site_id=...) instead.", + DeprecationWarning, + ) + + +# 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: - import warnings - - warnings.warn( - "TableauAuth(..., site=...) is deprecated, " "please use TableauAuth(..., site_id=...) instead.", - DeprecationWarning, - ) + deprecate_site_attribute() site_id = site + super().__init__(site_id, user_id_to_impersonate) if password is None: raise TabError("Must provide a password when using traditional authentication") - - self.user_id_to_impersonate = user_id_to_impersonate self.password = password - self.site_id = site_id if site_id is not None else "" self.username = username @property - def site(self): - import warnings + def credentials(self): + return {"name": self.username, "password": self.password} - warnings.warn( - "TableauAuth.site is deprecated, use TableauAuth.site_id instead.", - DeprecationWarning, - ) + def __repr__(self): + return "".format(self.username, "") + + @property + def site(self): + deprecate_site_attribute() return self.site_id @site.setter def site(self, value): - import warnings - - warnings.warn( - "TableauAuth.site is deprecated, use TableauAuth.site_id instead.", - DeprecationWarning, - ) + deprecate_site_attribute() self.site_id = value + +class PersonalAccessTokenAuth(Credentials): + def __init__(self, token_name, personal_access_token, site_id=None): + super().__init__(site_id=site_id) + self.token_name = token_name + self.personal_access_token = personal_access_token + @property def credentials(self): - return {"name": self.username, "password": self.password} + return { + "personalAccessTokenName": self.token_name, + "personalAccessTokenSecret": self.personal_access_token, + } + + def __repr__(self): + return "".format( + self.token_name, self.personal_access_token[:2] + "...", self.site_id + ) diff --git a/tableauserverclient/models/tableau_types.py b/tableauserverclient/models/tableau_types.py new file mode 100644 index 000000000..feaf02873 --- /dev/null +++ b/tableauserverclient/models/tableau_types.py @@ -0,0 +1,31 @@ +from tableauserverclient.models.database_item import DatabaseItem +from tableauserverclient.models.datasource_item import DatasourceItem +from tableauserverclient.models.flow_item import FlowItem +from tableauserverclient.models.project_item import ProjectItem +from tableauserverclient.models.table_item import TableItem +from tableauserverclient.models.view_item import ViewItem +from tableauserverclient.models.workbook_item import WorkbookItem + +from typing import Union + + +class Resource: + Database = "database" + Datasource = "datasource" + Flow = "flow" + Lens = "lens" + Project = "project" + Table = "table" + View = "view" + Workbook = "workbook" + + +# resource types that have permissions, can be renamed, etc +TableauItem = Union[DatasourceItem, FlowItem, ProjectItem, ViewItem, WorkbookItem] + + +def plural_type(content_type: Resource) -> str: + if content_type == Resource.Lens: + return "lenses" + else: + return "{}s".format(content_type) diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index b94f33725..f60e72951 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -1,3 +1,4 @@ +from datetime import datetime import xml.etree.ElementTree as ET from datetime import datetime from typing import Dict, List, Optional, TYPE_CHECKING @@ -13,6 +14,11 @@ from .reference_item import ResourceReference from ..datetime_helpers import parse_datetime +if TYPE_CHECKING: + from ..server.pager import Pager + +from typing import Dict, List, Optional, TYPE_CHECKING + if TYPE_CHECKING: from ..server.pager import Pager diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index 146f21077..01635349b 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -1,5 +1,5 @@ import copy -from typing import Callable, Iterable, List, Optional, Set, TYPE_CHECKING +from typing import Callable, Generator, Iterator, List, Optional, Set, TYPE_CHECKING from defusedxml.ElementTree import fromstring @@ -24,8 +24,8 @@ def __init__(self) -> None: self._preview_image: Optional[Callable[[], bytes]] = None self._project_id: Optional[str] = None self._pdf: Optional[Callable[[], bytes]] = None - self._csv: Optional[Callable[[], Iterable[bytes]]] = None - self._excel: Optional[Callable[[], Iterable[bytes]]] = None + self._csv: Optional[Callable[[], Iterator[bytes]]] = None + self._excel: Optional[Callable[[], Iterator[bytes]]] = None self._total_views: Optional[int] = None self._sheet_type: Optional[str] = None self._updated_at: Optional["datetime"] = None @@ -94,14 +94,14 @@ def pdf(self) -> bytes: return self._pdf() @property - def csv(self) -> Iterable[bytes]: + def csv(self) -> Iterator[bytes]: if self._csv is None: error = "View item must be populated with its csv first." raise UnpopulatedPropertyError(error) return self._csv() @property - def excel(self) -> Iterable[bytes]: + def excel(self) -> Iterator[bytes]: if self._excel is None: error = "View item must be populated with its excel first." raise UnpopulatedPropertyError(error) diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 949970ced..0d18e770d 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -29,6 +29,7 @@ from .connection_item import ConnectionItem from .permissions_item import PermissionsRule import datetime + from .revision_item import RevisionItem class WorkbookItem(object): @@ -124,7 +125,6 @@ def project_id(self) -> Optional[str]: return self._project_id @project_id.setter - @property_not_nullable def project_id(self, value: str): self._project_id = value diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index 5d1fb961b..cb680d914 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -36,6 +36,9 @@ ViewItem, WebhookItem, WorkbookItem, + TableauItem, + Resource, + plural_type, ) from .endpoint import ( Auth, @@ -59,3 +62,4 @@ from .server import Server from .pager import Pager from .exceptions import NotSignedInError +from ..helpers import * diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index 255b7b7a3..1fab7ac4b 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -5,7 +5,7 @@ from .endpoint import api, Endpoint from .exceptions import MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint -from .. import RequestFactory, DatabaseItem, TableItem, PaginationItem, Permission +from .. import RequestFactory, DatabaseItem, TableItem, PaginationItem, Resource logger = logging.getLogger("tableau.endpoint.databases") @@ -16,7 +16,7 @@ def __init__(self, parent_srv): self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl) - self._data_quality_warnings = _DataQualityWarningEndpoint(parent_srv, "database") + self._data_quality_warnings = _DataQualityWarningEndpoint(parent_srv, Resource.Database) @property def baseurl(self): @@ -108,15 +108,15 @@ def delete_permission(self, item, rules): @api(version="3.5") def populate_table_default_permissions(self, item): - self._default_permissions.populate_default_permissions(item, Permission.Resource.Table) + self._default_permissions.populate_default_permissions(item, Resource.Table) @api(version="3.5") def update_table_default_permissions(self, item): - return self._default_permissions.update_default_permissions(item, Permission.Resource.Table) + return self._default_permissions.update_default_permissions(item, Resource.Table) @api(version="3.5") def delete_table_default_permissions(self, item): - self._default_permissions.delete_default_permissions(item, Permission.Resource.Table) + self._default_permissions.delete_default_permissions(item, Resource.Table) @api(version="3.5") def populate_dqw(self, item): diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index cb5600938..022523aa4 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -339,7 +339,7 @@ def update_hyper_data( *, request_id: str, actions: Sequence[Mapping], - payload: Optional[FilePath] = None + payload: Optional[FilePath] = None, ) -> JobItem: if isinstance(datasource_or_connection_item, DatasourceItem): datasource_id = datasource_or_connection_item.id diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py index 6e54d02c7..66fc23d49 100644 --- a/tableauserverclient/server/endpoint/default_permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py @@ -3,56 +3,53 @@ from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError from .. import RequestFactory -from ...models import PermissionsRule - -logger = logging.getLogger(__name__) - +from ...models import DatabaseItem, PermissionsRule, ProjectItem, plural_type, Resource from typing import TYPE_CHECKING, Callable, List, Optional, Sequence, Union if TYPE_CHECKING: - from ...models import ( - DatasourceItem, - FlowItem, - ProjectItem, - ViewItem, - WorkbookItem, - ) - from ..server import Server from ..request_options import RequestOptions - TableauItem = Union[DatasourceItem, FlowItem, ProjectItem, ViewItem, WorkbookItem] +logger = logging.getLogger(__name__) + +# these are the only two items that can hold default permissions for another type +BaseItem = Union[DatabaseItem, ProjectItem] class _DefaultPermissionsEndpoint(Endpoint): - """Adds default-permission model to another endpoint + """Adds default-permission model to an existing database or project - Tableau default-permissions model applies only to databases and projects - and then takes an object type in the uri to set the defaults. - This class is meant to be instantated inside a parent endpoint which + Tableau default-permissions model takes an object type in the uri to set the defaults. + This class is meant to be instantiated inside a parent endpoint which has these supported endpoints """ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> None: super(_DefaultPermissionsEndpoint, self).__init__(parent_srv) - # owner_baseurl is the baseurl of the parent. The MUST be a lambda - # since we don't know the full site URL until we sign in. If - # populated without, we will get a sign-in error + # owner_baseurl is the baseurl of the parent, a project or database. + # It MUST be a lambda since we don't know the full site URL until we sign in. + # If populated without, we will get a sign-in error self.owner_baseurl = owner_baseurl + def __str__(self): + return "".format(self.owner_baseurl()) + + __repr__ = __str__ + def update_default_permissions( - self, resource: "TableauItem", permissions: Sequence[PermissionsRule], content_type: str + self, resource: BaseItem, permissions: Sequence[PermissionsRule], content_type: Resource ) -> List[PermissionsRule]: - url = "{0}/{1}/default-permissions/{2}".format(self.owner_baseurl(), resource.id, content_type + "s") + url = "{0}/{1}/default-permissions/{2}".format(self.owner_baseurl(), resource.id, plural_type(content_type)) update_req = RequestFactory.Permission.add_req(permissions) response = self.put_request(url, update_req) permissions = PermissionsRule.from_response(response.content, self.parent_srv.namespace) - logger.info("Updated permissions for resource {0}".format(resource.id)) + logger.info("Updated default {} permissions for resource {}".format(content_type, resource.id)) + logger.info(permissions) return permissions - def delete_default_permission(self, resource: "TableauItem", rule: PermissionsRule, content_type: str) -> None: + def delete_default_permission(self, resource: BaseItem, rule: PermissionsRule, content_type: Resource) -> None: for capability, mode in rule.capabilities.items(): # Made readability better but line is too long, will make this look better url = ( @@ -60,7 +57,7 @@ def delete_default_permission(self, resource: "TableauItem", rule: PermissionsRu "{content_type}/{grantee_type}/{grantee_id}/{cap}/{mode}".format( baseurl=self.owner_baseurl(), content_id=resource.id, - content_type=content_type + "s", + content_type=plural_type(content_type), grantee_type=rule.grantee.tag_name + "s", grantee_id=rule.grantee.id, cap=capability, @@ -68,7 +65,7 @@ def delete_default_permission(self, resource: "TableauItem", rule: PermissionsRu ) ) - logger.debug("Removing {0} permission for capabilty {1}".format(mode, capability)) + logger.debug("Removing {0} permission for capability {1}".format(mode, capability)) self.delete_request(url) @@ -76,7 +73,7 @@ def delete_default_permission(self, resource: "TableauItem", rule: PermissionsRu "Deleted permission for {0} {1} item {2}".format(rule.grantee.tag_name, rule.grantee.id, resource.id) ) - def populate_default_permissions(self, item: "ProjectItem", content_type: str) -> None: + def populate_default_permissions(self, item: BaseItem, content_type: Resource) -> None: if not item.id: error = "Server item is missing ID. Item must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -85,13 +82,13 @@ def permission_fetcher() -> List[PermissionsRule]: return self._get_default_permissions(item, content_type) item._set_default_permissions(permission_fetcher, content_type) - logger.info("Populated {0} permissions for item (ID: {1})".format(item.id, content_type)) + logger.info("Populated default {0} permissions for item (ID: {1})".format(content_type, item.id)) def _get_default_permissions( - self, item: "TableauItem", content_type: str, req_options: Optional["RequestOptions"] = None + self, item: BaseItem, content_type: Resource, req_options: Optional["RequestOptions"] = None ) -> List[PermissionsRule]: - url = "{0}/{1}/default-permissions/{2}".format(self.owner_baseurl(), item.id, content_type + "s") + url = "{0}/{1}/default-permissions/{2}".format(self.owner_baseurl(), item.id, plural_type(content_type)) server_response = self.get_request(url, req_options) permissions = PermissionsRule.from_response(server_response.content, self.parent_srv.namespace) - + logger.info({"content_type": content_type, "permissions": permissions}) return permissions diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 8fdb74751..0acc978d2 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,7 +1,9 @@ +import requests import logging from distutils.version import LooseVersion as Version from functools import wraps from xml.etree.ElementTree import ParseError +from typing import Any, Callable, Dict, Optional, TYPE_CHECKING from .exceptions import ( ServerResponseError, @@ -10,6 +12,7 @@ EndpointUnavailableError, ) from ..query import QuerySet +from ... import helpers logger = logging.getLogger("tableau.endpoint") @@ -18,9 +21,13 @@ XML_CONTENT_TYPE = "text/xml" JSON_CONTENT_TYPE = "application/json" +if TYPE_CHECKING: + from ..server import Server + from requests import Response + class Endpoint(object): - def __init__(self, parent_srv): + def __init__(self, parent_srv: "Server"): self.parent_srv = parent_srv @staticmethod @@ -33,29 +40,18 @@ def _make_common_headers(auth_token, content_type): return headers - @staticmethod - def _safe_to_log(server_response): - """Checks if the server_response content is not xml (eg binary image or zip) - and replaces it with a constant - """ - ALLOWED_CONTENT_TYPES = ("application/xml", "application/xml;charset=utf-8") - if server_response.headers.get("Content-Type", None) not in ALLOWED_CONTENT_TYPES: - return "[Truncated File Contents]" - else: - return server_response.content - def _make_request( self, - method, - url, - content=None, - auth_token=None, - content_type=None, - parameters=None, - ): + method: Callable[..., "Response"], + url: str, + content: Optional[bytes] = None, + auth_token: Optional[str] = None, + content_type: Optional[str] = None, + parameters: Optional[Dict[str, Any]] = None, + ) -> "Response": parameters = parameters or {} parameters.update(self.parent_srv.http_options) - if not "headers" in parameters: + if "headers" not in parameters: parameters["headers"] = {} parameters["headers"].update(Endpoint._make_common_headers(auth_token, content_type)) @@ -64,29 +60,29 @@ def _make_request( logger.debug("request {}, url: {}".format(method.__name__, url)) if content: - logger.debug("request content: {}".format(content[:1000])) + logger.debug("request content: {}".format(helpers.strings.redact_xml(content[:1000]))) server_response = method(url, **parameters) - self.parent_srv._namespace.detect(server_response.content) self._check_status(server_response) - # This check is to determine if the response is a text response (xml or otherwise) - # so that we do not attempt to log bytes and other binary data. - if len(server_response.content) > 0 and server_response.encoding: - logger.debug( - "Server response from {0}:\n\t{1}".format(url, server_response.content.decode(server_response.encoding)) - ) + loggable_response = self.log_response_safely(server_response) + logger.debug("Server response from {0}:\n\t{1}".format(url, loggable_response)) + + if content_type == "application/xml": + self.parent_srv._namespace.detect(server_response.content) + return server_response def _check_status(self, server_response): if server_response.status_code >= 500: raise InternalServerError(server_response) elif server_response.status_code not in Success_codes: + # todo: is an error reliably of content-type application/xml? try: raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace) except ParseError: # This will happen if we get a non-success HTTP code that - # doesn't return an xml error object (like metadata endpoints) + # doesn't return an xml error object (like metadata endpoints or 503 pages) # we convert this to a better exception and pass through the raw # response body raise NonXMLResponseError(server_response.content) @@ -94,6 +90,21 @@ def _check_status(self, server_response): # anything else re-raise here raise + def log_response_safely(self, server_response: requests.Response) -> str: + # Checking the content type header prevents eager evaluation of streaming requests. + content_type = server_response.headers.get("Content-Type") + + # Response.content is a property. Calling it will load the entire response into memory. Checking if the + # content-type is an octet-stream accomplishes the same goal without eagerly loading content. + # This check is to determine if the response is a text response (xml or otherwise) + # so that we do not attempt to log bytes and other binary data. + loggable_response = "Content type {}".format(content_type) + if content_type == "application/octet-stream": + loggable_response = "A stream of type {} [Truncated File Contents]".format(content_type) + elif server_response.encoding and len(server_response.content) > 0: + loggable_response = helpers.strings.redact_xml(server_response.content.decode(server_response.encoding)) + return loggable_response + def get_unauthenticated_request(self, url): return self._make_request(self.parent_srv.session.get, url) @@ -118,7 +129,7 @@ def delete_request(self, url): # We don't return anything for a delete self._make_request(self.parent_srv.session.delete, url, auth_token=self.parent_srv.auth_token) - def put_request(self, url, xml_request=None, content_type="text/xml", parameters=None): + def put_request(self, url, xml_request=None, content_type=XML_CONTENT_TYPE, parameters=None): return self._make_request( self.parent_srv.session.put, url, @@ -128,7 +139,7 @@ def put_request(self, url, xml_request=None, content_type="text/xml", parameters parameters=parameters, ) - def post_request(self, url, xml_request, content_type="text/xml", parameters=None): + def post_request(self, url, xml_request, content_type=XML_CONTENT_TYPE, parameters=None): return self._make_request( self.parent_srv.session.post, url, @@ -138,7 +149,7 @@ def post_request(self, url, xml_request, content_type="text/xml", parameters=Non parameters=parameters, ) - def patch_request(self, url, xml_request, content_type="text/xml", parameters=None): + def patch_request(self, url, xml_request, content_type=XML_CONTENT_TYPE, parameters=None): return self._make_request( self.parent_srv.session.patch, url, diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py index 10a1d9fac..f7c2f9f13 100644 --- a/tableauserverclient/server/endpoint/permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/permissions_endpoint.py @@ -5,17 +5,15 @@ from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError +from ...models import TableauItem from typing import Callable, TYPE_CHECKING, List, Union logger = logging.getLogger(__name__) if TYPE_CHECKING: - from ...models import DatasourceItem, ProjectItem, WorkbookItem, ViewItem from ..server import Server from ..request_options import RequestOptions -TableauItem = Union["DatasourceItem", "ProjectItem", "WorkbookItem", "ViewItem"] - class _PermissionsEndpoint(Endpoint): """Adds permission model to another endpoint @@ -34,12 +32,15 @@ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> No # populated without, we will get a sign-in error self.owner_baseurl = owner_baseurl + def __str__(self): + return "".format(self.owner_baseurl) + def update(self, resource: TableauItem, permissions: List[PermissionsRule]) -> List[PermissionsRule]: url = "{0}/{1}/permissions".format(self.owner_baseurl(), resource.id) update_req = RequestFactory.Permission.add_req(permissions) response = self.put_request(url, update_req) permissions = PermissionsRule.from_response(response.content, self.parent_srv.namespace) - logger.info("Updated permissions for resource {0}".format(resource.id)) + logger.info("Updated permissions for resource {0}: {1}".format(resource.id, permissions)) return permissions @@ -62,7 +63,7 @@ def delete(self, resource: TableauItem, rules: Union[PermissionsRule, List[Permi mode, ) - logger.debug("Removing {0} permission for capabilty {1}".format(mode, capability)) + logger.debug("Removing {0} permission for capability {1}".format(mode, capability)) self.delete_request(url) @@ -85,5 +86,6 @@ def _get_permissions(self, item: TableauItem, req_options: "RequestOptions" = No url = "{0}/{1}/permissions".format(self.owner_baseurl(), item.id) server_response = self.get_request(url, req_options) permissions = PermissionsRule.from_response(server_response.content, self.parent_srv.namespace) + logger.info("Permissions for resource {0}: {1}".format(item.id, permissions)) return permissions diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index b21ba3682..e268d2011 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -4,9 +4,7 @@ from .endpoint import QuerysetEndpoint, api, XML_CONTENT_TYPE from .exceptions import MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint -from .. import RequestFactory, RequestOptions, ProjectItem, PaginationItem, Permission - -logger = logging.getLogger("tableau.endpoint.projects") +from .. import RequestFactory, RequestOptions, ProjectItem, PaginationItem, Resource from typing import List, Optional, Tuple, TYPE_CHECKING @@ -14,6 +12,8 @@ from ..server import Server from ..request_options import RequestOptions +logger = logging.getLogger("tableau.endpoint.projects") + class Projects(QuerysetEndpoint): def __init__(self, parent_srv: "Server") -> None: @@ -93,36 +93,48 @@ def delete_permission(self, item, rules): @api(version="2.1") def populate_workbook_default_permissions(self, item): - self._default_permissions.populate_default_permissions(item, Permission.Resource.Workbook) + self._default_permissions.populate_default_permissions(item, Resource.Workbook) @api(version="2.1") def populate_datasource_default_permissions(self, item): - self._default_permissions.populate_default_permissions(item, Permission.Resource.Datasource) + self._default_permissions.populate_default_permissions(item, Resource.Datasource) @api(version="3.4") def populate_flow_default_permissions(self, item): - self._default_permissions.populate_default_permissions(item, Permission.Resource.Flow) + self._default_permissions.populate_default_permissions(item, Resource.Flow) + + @api(version="3.4") + def populate_lens_default_permissions(self, item): + self._default_permissions.populate_default_permissions(item, Resource.Lens) @api(version="2.1") def update_workbook_default_permissions(self, item, rules): - return self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Workbook) + return self._default_permissions.update_default_permissions(item, rules, Resource.Workbook) @api(version="2.1") def update_datasource_default_permissions(self, item, rules): - return self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Datasource) + return self._default_permissions.update_default_permissions(item, rules, Resource.Datasource) @api(version="3.4") def update_flow_default_permissions(self, item, rules): - return self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Flow) + return self._default_permissions.update_default_permissions(item, rules, Resource.Flow) + + @api(version="3.4") + def update_lens_default_permissions(self, item, rules): + return self._default_permissions.update_default_permissions(item, rules, Resource.Lens) @api(version="2.1") def delete_workbook_default_permissions(self, item, rule): - self._default_permissions.delete_default_permission(item, rule, Permission.Resource.Workbook) + self._default_permissions.delete_default_permission(item, rule, Resource.Workbook) @api(version="2.1") def delete_datasource_default_permissions(self, item, rule): - self._default_permissions.delete_default_permission(item, rule, Permission.Resource.Datasource) + self._default_permissions.delete_default_permission(item, rule, Resource.Datasource) @api(version="3.4") def delete_flow_default_permissions(self, item, rule): - self._default_permissions.delete_default_permission(item, rule, Permission.Resource.Flow) + self._default_permissions.delete_default_permission(item, rule, Resource.Flow) + + @api(version="3.4") + def delete_lens_default_permissions(self, item, rule): + self._default_permissions.delete_default_permission(item, rule, Resource.Lens) diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index 339952704..f147c79ae 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -53,7 +53,7 @@ def get_by_id(self, task_id): @api(version="2.6") def run(self, task_item): if not task_item.id: - error = "User item missing ID." + error = "Task item missing ID." raise MissingRequiredFieldError(error) url = "{0}/{1}/{2}/runNow".format( @@ -63,7 +63,7 @@ def run(self, task_item): ) run_req = RequestFactory.Task.run_req(task_item) server_response = self.post_request(url, run_req) - return server_response.content + return server_response.content # Todo add typing # Delete 1 task by id @api(version="3.6") diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index a1984d5d6..738364cd7 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -1,6 +1,6 @@ import copy import logging -from typing import List, Tuple +from typing import List, Optional, Tuple from .endpoint import QuerysetEndpoint, api from .exceptions import MissingRequiredFieldError @@ -64,11 +64,13 @@ def update(self, user_item: UserItem, password: str = None) -> UserItem: # Delete 1 user by id @api(version="2.0") - def remove(self, user_id: str) -> None: + def remove(self, user_id: str, map_assets_to: Optional[str] = None) -> None: if not user_id: error = "User ID undefined." raise ValueError(error) url = "{0}/{1}".format(self.baseurl, user_id) + if map_assets_to is not None: + url += f"?mapAssetsTo={map_assets_to}" self.delete_request(url) logger.info("Removed single user (ID: {0})".format(user_id)) diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index cb652fbc0..67e66a81f 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -9,7 +9,7 @@ logger = logging.getLogger("tableau.endpoint.views") -from typing import Iterable, List, Optional, Tuple, TYPE_CHECKING +from typing import Iterator, List, Optional, Tuple, TYPE_CHECKING if TYPE_CHECKING: from ..request_options import RequestOptions, CSVRequestOptions, PDFRequestOptions, ImageRequestOptions @@ -119,12 +119,11 @@ def csv_fetcher(): view_item._set_csv(csv_fetcher) logger.info("Populated csv for view (ID: {0})".format(view_item.id)) - def _get_view_csv(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"]) -> Iterable[bytes]: + def _get_view_csv(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"]) -> Iterator[bytes]: url = "{0}/{1}/data".format(self.baseurl, view_item.id) with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: - csv = server_response.iter_content(1024) - return csv + yield from server_response.iter_content(1024) @api(version="3.8") def populate_excel(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"] = None) -> None: @@ -138,12 +137,11 @@ def excel_fetcher(): view_item._set_excel(excel_fetcher) logger.info("Populated excel for view (ID: {0})".format(view_item.id)) - def _get_view_excel(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"]) -> Iterable[bytes]: + def _get_view_excel(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"]) -> Iterator[bytes]: url = "{0}/{1}/crosstab/excel".format(self.baseurl, view_item.id) with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: - excel = server_response.iter_content(1024) - return excel + yield from server_response.iter_content(1024) @api(version="3.2") def populate_permissions(self, item: ViewItem) -> None: diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 901d0e62a..4d7a4a2b5 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -16,6 +16,7 @@ from .endpoint import QuerysetEndpoint, api, parameter_added_in from .exceptions import InternalServerError, MissingRequiredFieldError +from ...helpers import redact_xml from .permissions_endpoint import _PermissionsEndpoint from .resource_tagger import _ResourceTagger from .. import RequestFactory, WorkbookItem, ConnectionItem, ViewItem, PaginationItem @@ -441,7 +442,7 @@ def publish( connections=connections, hidden_views=hidden_views, ) - logger.debug("Request xml: {0} ".format(xml_request[:1000])) + logger.debug("Request xml: {0} ".format(redact_xml(xml_request[:1000]))) # Send the publishing request to server try: diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index 64a7107aa..729447822 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -71,7 +71,8 @@ def __getitem__(self, k): elif k in range(self.total_available): # Otherwise, check if k is even sensible to return self._result_cache = None - self.request_options.pagenumber = max(1, math.ceil(k / size)) + # 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] else: # If k is unreasonable, raise an IndexError. diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 7e4038979..fc00ca085 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -16,6 +16,16 @@ from ..models import TaskItem, UserItem, GroupItem, PermissionsRule, FavoriteItem from ..models import WebhookItem +from typing import Any, Dict, List, Optional, TYPE_CHECKING, Tuple, Iterable + +if TYPE_CHECKING: + from ..models import SubscriptionItem + from ..models import DataAlertItem + from ..models import FlowItem + from ..models import ConnectionItem + from ..models import SiteItem + from ..models import ProjectItem + def _add_multipart(parts: Dict) -> Tuple[Any, str]: mime_multipart_parts = list() @@ -39,6 +49,8 @@ def wrapper(self, *args, **kwargs): def _add_connections_element(connections_element, connection): connection_element = ET.SubElement(connections_element, "connection") + if not connection.server_address: + raise ValueError("Connection must have a server address") connection_element.attrib["serverAddress"] = connection.server_address if connection.server_port: connection_element.attrib["serverPort"] = connection.server_port @@ -55,6 +67,8 @@ def _add_hiddenview_element(views_element, view_name): def _add_credentials_element(parent_element, connection_credentials): credentials_element = ET.SubElement(parent_element, "connectionCredentials") + if not connection_credentials.password or not connection_credentials.name: + raise ValueError("Connection Credentials must have a name and password") credentials_element.attrib["name"] = connection_credentials.name credentials_element.attrib["password"] = connection_credentials.password credentials_element.attrib["embed"] = "true" if connection_credentials.embed else "false" @@ -232,7 +246,7 @@ def add_req(self, dqw_item): return ET.tostring(xml_request) - def update_req(self, database_item): + def update_req(self, dqw_item): xml_request = ET.Element("tsRequest") dqw_element = ET.SubElement(xml_request, "dataQualityWarning") @@ -877,7 +891,6 @@ def _generate_xml( views_element = ET.SubElement(workbook_element, "views") for view_name in workbook_item.hidden_views: _add_hiddenview_element(views_element, view_name) - return ET.tostring(xml_request) def update_req(self, workbook_item): @@ -950,9 +963,9 @@ def embedded_extract_req(self, xml_request, include_all=True, datasources=None): list_element = ET.SubElement(xml_request, "datasources") if include_all: list_element.attrib["includeAll"] = "true" - else: + elif datasources: for datasource_item in datasources: - datasource_element = list_element.SubElement(xml_request, "datasource") + datasource_element = ET.SubElement(list_element, "datasource") datasource_element.attrib["id"] = datasource_item.id diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 36ffccd8e..4462ba786 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -46,10 +46,13 @@ class Field: OwnerDomain = "ownerDomain" OwnerEmail = "ownerEmail" OwnerName = "ownerName" + ParentProjectId = "parentProjectId" Progress = "progress" ProjectName = "projectName" PublishSamples = "publishSamples" SiteRole = "siteRole" + StartedAt = "startedAt" + Status = "status" Subtitle = "subtitle" Tags = "tags" Title = "title" diff --git a/test/assets/job_get_by_id_failed_workbook.xml b/test/assets/job_get_by_id_failed_workbook.xml new file mode 100644 index 000000000..bf81d896e --- /dev/null +++ b/test/assets/job_get_by_id_failed_workbook.xml @@ -0,0 +1,9 @@ + + + + + + java.lang.RuntimeException: [Microsoft][ODBC Driver 17 for SQL Server][SQL Server]Login failed for user.\nIntegrated authentication failed. + + + diff --git a/test/assets/queryset_slicing_page_1.xml b/test/assets/queryset_slicing_page_1.xml new file mode 100644 index 000000000..be3df91f8 --- /dev/null +++ b/test/assets/queryset_slicing_page_1.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/queryset_slicing_page_2.xml b/test/assets/queryset_slicing_page_2.xml new file mode 100644 index 000000000..058bbd5c0 --- /dev/null +++ b/test/assets/queryset_slicing_page_2.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/request_factory/__init__.py b/test/request_factory/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/request_factory/test_datasource_requests.py b/test/request_factory/test_datasource_requests.py new file mode 100644 index 000000000..75bb535d5 --- /dev/null +++ b/test/request_factory/test_datasource_requests.py @@ -0,0 +1,15 @@ +import unittest +import tableauserverclient as TSC +import tableauserverclient.server.request_factory as TSC_RF +from tableauserverclient import DatasourceItem + + +class DatasourceRequestTests(unittest.TestCase): + def test_generate_xml(self): + datasource_item: TSC.DatasourceItem = TSC.DatasourceItem("name") + datasource_item.name = "a ds" + datasource_item.description = "described" + datasource_item.use_remote_query_agent = False + datasource_item.ask_data_enablement = DatasourceItem.AskDataEnablement.Enabled + datasource_item.project_id = "testval" + TSC_RF.RequestFactory.Datasource._generate_xml(datasource_item) diff --git a/test/request_factory/test_workbook_requests.py b/test/request_factory/test_workbook_requests.py new file mode 100644 index 000000000..332b6defa --- /dev/null +++ b/test/request_factory/test_workbook_requests.py @@ -0,0 +1,55 @@ +import unittest +import tableauserverclient as TSC +import tableauserverclient.server.request_factory as TSC_RF +from tableauserverclient.helpers.strings import redact_xml +import pytest +import sys + + +class WorkbookRequestTests(unittest.TestCase): + def test_embedded_extract_req(self): + include_all = True + embedded_datasources = None + xml_result = TSC_RF.RequestFactory.Workbook.embedded_extract_req(include_all, embedded_datasources) + + def test_generate_xml(self): + workbook_item: TSC.WorkbookItem = TSC.WorkbookItem("name", "project_id") + TSC_RF.RequestFactory.Workbook._generate_xml(workbook_item) + + def test_generate_xml_invalid_connection(self): + workbook_item: TSC.WorkbookItem = TSC.WorkbookItem("name", "project_id") + conn = TSC.ConnectionItem() + with self.assertRaises(ValueError): + request = TSC_RF.RequestFactory.Workbook._generate_xml(workbook_item, connections=[conn]) + + def test_generate_xml_invalid_connection_credentials(self): + workbook_item: TSC.WorkbookItem = TSC.WorkbookItem("name", "project_id") + conn = TSC.ConnectionItem() + conn.server_address = "address" + creds = TSC.ConnectionCredentials("username", "password") + creds.name = None + conn.connection_credentials = creds + with self.assertRaises(ValueError): + request = TSC_RF.RequestFactory.Workbook._generate_xml(workbook_item, connections=[conn]) + + def test_generate_xml_valid_connection_credentials(self): + workbook_item: TSC.WorkbookItem = TSC.WorkbookItem("name", "project_id") + conn = TSC.ConnectionItem() + conn.server_address = "address" + creds = TSC.ConnectionCredentials("username", "DELETEME") + conn.connection_credentials = creds + request = TSC_RF.RequestFactory.Workbook._generate_xml(workbook_item, connections=[conn]) + assert request.find(b"DELETEME") > 0 + + def test_redact_passwords_in_xml(self): + if sys.version_info < (3, 7): + pytest.skip("Redaction is only implemented for 3.7+.") + workbook_item: TSC.WorkbookItem = TSC.WorkbookItem("name", "project_id") + conn = TSC.ConnectionItem() + conn.server_address = "address" + creds = TSC.ConnectionCredentials("username", "DELETEME") + conn.connection_credentials = creds + request = TSC_RF.RequestFactory.Workbook._generate_xml(workbook_item, connections=[conn]) + redacted = redact_xml(request) + assert request.find(b"DELETEME") > 0, request + assert redacted.find(b"DELETEME") == -1, redacted diff --git a/test/test_dqw.py b/test/test_dqw.py new file mode 100644 index 000000000..6d1219f66 --- /dev/null +++ b/test/test_dqw.py @@ -0,0 +1,11 @@ +import unittest +import tableauserverclient as TSC + + +class DQWTests(unittest.TestCase): + def test_existence(self): + dqw: TSC.DQWItem = TSC.DQWItem() + dqw.message = "message" + dqw.warning_type = TSC.DQWItem.WarningType.STALE + dqw.active = True + dqw.severe = True diff --git a/test/test_endpoint.py b/test/test_endpoint.py new file mode 100644 index 000000000..e583a9188 --- /dev/null +++ b/test/test_endpoint.py @@ -0,0 +1,40 @@ +from pathlib import Path +import unittest + +import tableauserverclient as TSC + +import requests_mock + +ASSETS = Path(__file__).parent / "assets" + + +class TestEndpoint(unittest.TestCase): + def setUp(self) -> None: + self.server = TSC.Server("http://test/", use_server_version=False) + + # Fake signin + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + + return super().setUp() + + def test_get_request_stream(self) -> None: + url = "http://test/" + endpoint = TSC.server.Endpoint(self.server) + with requests_mock.mock() as m: + m.get(url, headers={"Content-Type": "application/octet-stream"}) + response = endpoint.get_request(url, parameters={"stream": True}) + + self.assertFalse(response._content_consumed) + + def test_binary_log_truncated(self): + class FakeResponse(object): + + headers = {"Content-Type": "application/octet-stream"} + content = b"\x1337" * 1000 + status_code = 200 + + endpoint = TSC.server.Endpoint(self.server) + server_response = FakeResponse() + log = endpoint.log_response_safely(server_response) + self.assertTrue(log.find("[Truncated File Contents]") > 0, log) diff --git a/test/test_job.py b/test/test_job.py index 6daa16afa..19a93e808 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -9,13 +9,12 @@ from tableauserverclient.server.endpoint.exceptions import JobFailedException from ._utils import read_xml_asset, mocked_time -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") - GET_XML = "job_get.xml" GET_BY_ID_XML = "job_get_by_id.xml" GET_BY_ID_FAILED_XML = "job_get_by_id_failed.xml" GET_BY_ID_CANCELLED_XML = "job_get_by_id_cancelled.xml" GET_BY_ID_INPROGRESS_XML = "job_get_by_id_inprogress.xml" +GET_BY_ID_WORKBOOK = "job_get_by_id_failed_workbook.xml" class JobTests(unittest.TestCase): @@ -103,3 +102,19 @@ def test_wait_for_job_timeout(self) -> None: m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) with self.assertRaises(TimeoutError): self.server.jobs.wait_for_job(job_id, timeout=30) + + def test_get_job_datasource_id(self) -> None: + response_xml = read_xml_asset(GET_BY_ID_FAILED_XML) + job_id = "777bf7c4-421d-4b2c-a518-11b90187c545" + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{job_id}", text=response_xml) + job = self.server.jobs.get_by_id(job_id) + self.assertEqual(job.datasource_id, "03b9fbec-81f6-4160-ae49-5f9f6d412758") + + def test_get_job_workbook_id(self) -> None: + response_xml = read_xml_asset(GET_BY_ID_WORKBOOK) + job_id = "bb1aab79-db54-4e96-9dd3-461d8f081d08" + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{job_id}", text=response_xml) + job = self.server.jobs.get_by_id(job_id) + self.assertEqual(job.workbook_id, "5998aaaf-1abe-4d38-b4d9-bc53e85bdd13") diff --git a/test/test_regression_tests.py b/test/test_regression_tests.py index 58d6329db..772704f69 100644 --- a/test/test_regression_tests.py +++ b/test/test_regression_tests.py @@ -6,7 +6,7 @@ import mock # type: ignore[no-redef] import tableauserverclient.server.request_factory as factory -from tableauserverclient.server.endpoint import Endpoint +from tableauserverclient.helpers.strings import redact_xml from tableauserverclient.filesys_helpers import to_filename, make_download_path @@ -16,19 +16,6 @@ def test_empty_request_works(self): self.assertEqual(b"", result) -class BugFix273(unittest.TestCase): - def test_binary_log_truncated(self): - class FakeResponse(object): - - headers = {"Content-Type": "application/octet-stream"} - content = b"\x1337" * 1000 - status_code = 200 - - server_response = FakeResponse() - - self.assertEqual(Endpoint._safe_to_log(server_response), "[Truncated File Contents]") - - class FileSysHelpers(unittest.TestCase): def test_to_filename(self): invalid = [ @@ -60,3 +47,41 @@ def test_make_download_path(self): with mock.patch("os.path.isdir") as mocked_isdir: mocked_isdir.return_value = True self.assertEqual("/root/folder/file.ext", make_download_path(*has_file_path_folder)) + + +class LoggingTest(unittest.TestCase): + def test_redact_password_string(self): + redacted = redact_xml( + "this is password: my_super_secret_passphrase_which_nobody_should_ever_see password: value" + ) + assert redacted.find("value") == -1 + assert redacted.find("secret") == -1 + assert redacted.find("ever_see") == -1 + assert redacted.find("my_super_secret_passphrase_which_nobody_should_ever_see") == -1 + + def test_redact_password_bytes(self): + redacted = redact_xml( + b"" + ) + assert redacted.find(b"value") == -1 + assert redacted.find(b"secret") == -1 + + def test_redact_password_with_special_char(self): + redacted = redact_xml( + " " + ) + assert redacted.find("my_s per_secre>_passphrase_which_nobody_should_ever_see with password: value") == -1 + + def test_redact_password_not_xml(self): + redacted = redact_xml( + " " + ) + assert redacted.find("my_s per_secre>_passphrase_which_nobody_should_ever_see") == -1 + + def test_redact_password_really_not_xml(self): + redacted = redact_xml( + "value='this is a nondescript text line which is public' password='my_s per_secre>_passphrase_which_nobody_should_ever_see with password: value and then a cookie " + ) + assert redacted.find("my_s per_secre>_passphrase_which_nobody_should_ever_see") == -1 + assert redacted.find("passphrase") == -1, redacted + assert redacted.find("cookie") == -1, redacted diff --git a/test/test_request_option.py b/test/test_request_option.py index ed8d55bb0..9dacbe033 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -1,4 +1,5 @@ import os +from pathlib import Path import re import unittest @@ -6,7 +7,7 @@ import tableauserverclient as TSC -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") +TEST_ASSET_DIR = Path(__file__).parent / "assets" PAGINATION_XML = os.path.join(TEST_ASSET_DIR, "request_option_pagination.xml") PAGE_NUMBER_XML = os.path.join(TEST_ASSET_DIR, "request_option_page_number.xml") @@ -15,10 +16,12 @@ 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") +SLICING_QUERYSET_PAGE_1 = TEST_ASSET_DIR / "queryset_slicing_page_1.xml" +SLICING_QUERYSET_PAGE_2 = TEST_ASSET_DIR / "queryset_slicing_page_2.xml" class RequestOptionTests(unittest.TestCase): - def setUp(self): + def setUp(self) -> None: self.server = TSC.Server("http://test", False) # Fake signin @@ -28,7 +31,7 @@ def setUp(self): self.baseurl = "{0}/{1}".format(self.server.sites.baseurl, self.server._site_id) - def test_pagination(self): + def test_pagination(self) -> None: with open(PAGINATION_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: @@ -41,7 +44,7 @@ def test_pagination(self): self.assertEqual(33, pagination_item.total_available) self.assertEqual(10, len(all_views)) - def test_page_number(self): + def test_page_number(self) -> None: with open(PAGE_NUMBER_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: @@ -54,7 +57,7 @@ def test_page_number(self): self.assertEqual(210, pagination_item.total_available) self.assertEqual(10, len(all_views)) - def test_page_size(self): + def test_page_size(self) -> None: with open(PAGE_SIZE_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: @@ -67,7 +70,7 @@ def test_page_size(self): self.assertEqual(33, pagination_item.total_available) self.assertEqual(5, len(all_views)) - def test_filter_equals(self): + def test_filter_equals(self) -> None: with open(FILTER_EQUALS, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: @@ -82,7 +85,7 @@ def test_filter_equals(self): self.assertEqual("RESTAPISample", matching_workbooks[0].name) self.assertEqual("RESTAPISample", matching_workbooks[1].name) - def test_filter_equals_shorthand(self): + def test_filter_equals_shorthand(self) -> None: with open(FILTER_EQUALS, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: @@ -93,7 +96,7 @@ def test_filter_equals_shorthand(self): self.assertEqual("RESTAPISample", matching_workbooks[0].name) self.assertEqual("RESTAPISample", matching_workbooks[1].name) - def test_filter_tags_in(self): + def test_filter_tags_in(self) -> None: with open(FILTER_TAGS_IN, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: @@ -111,7 +114,7 @@ def test_filter_tags_in(self): self.assertEqual(set(["safari"]), matching_workbooks[1].tags) self.assertEqual(set(["sample"]), matching_workbooks[2].tags) - def test_filter_tags_in_shorthand(self): + def test_filter_tags_in_shorthand(self) -> None: with open(FILTER_TAGS_IN, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: @@ -123,11 +126,11 @@ def test_filter_tags_in_shorthand(self): self.assertEqual(set(["safari"]), matching_workbooks[1].tags) self.assertEqual(set(["sample"]), matching_workbooks[2].tags) - def test_invalid_shorthand_option(self): + def test_invalid_shorthand_option(self) -> None: with self.assertRaises(ValueError): self.server.workbooks.filter(nonexistant__in=["sample", "safari"]) - def test_multiple_filter_options(self): + def test_multiple_filter_options(self) -> None: with open(FILTER_MULTIPLE, "rb") as f: response_xml = f.read().decode("utf-8") # To ensure that this is deterministic, run this a few times @@ -153,7 +156,7 @@ def test_multiple_filter_options(self): self.assertEqual(3, pagination_item.total_available) # Test req_options if url already has query params - def test_double_query_params(self): + def test_double_query_params(self) -> None: with requests_mock.mock() as m: m.get(requests_mock.ANY) url = self.baseurl + "/views?queryParamExists=true" @@ -170,7 +173,7 @@ def test_double_query_params(self): self.assertTrue(re.search("sort=name%3aasc", resp.request.query)) # Test req_options for versions below 3.7 - def test_filter_sort_legacy(self): + def test_filter_sort_legacy(self) -> None: self.server.version = "3.6" with requests_mock.mock() as m: m.get(requests_mock.ANY) @@ -187,7 +190,7 @@ def test_filter_sort_legacy(self): self.assertTrue(re.search("filter=tags:in:%5bstocks,market%5d", resp.request.query)) self.assertTrue(re.search("sort=name:asc", resp.request.query)) - def test_vf(self): + def test_vf(self) -> None: with requests_mock.mock() as m: m.get(requests_mock.ANY) url = self.baseurl + "/views/456/data" @@ -202,7 +205,7 @@ def test_vf(self): self.assertTrue(re.search("type=tabloid", resp.request.query)) # Test req_options for versions beloe 3.7 - def test_vf_legacy(self): + def test_vf_legacy(self) -> None: self.server.version = "3.6" with requests_mock.mock() as m: m.get(requests_mock.ANY) @@ -217,7 +220,7 @@ def test_vf_legacy(self): self.assertTrue(re.search("vf_name2\\$=value2", resp.request.query)) self.assertTrue(re.search("type=tabloid", resp.request.query)) - def test_all_fields(self): + def test_all_fields(self) -> None: with requests_mock.mock() as m: m.get(requests_mock.ANY) url = self.baseurl + "/views/456/data" @@ -227,7 +230,7 @@ def test_all_fields(self): resp = self.server.users.get_request(url, request_object=opts) self.assertTrue(re.search("fields=_all_", resp.request.query)) - def test_multiple_filter_options_shorthand(self): + def test_multiple_filter_options_shorthand(self) -> None: with open(FILTER_MULTIPLE, "rb") as f: response_xml = f.read().decode("utf-8") # To ensure that this is deterministic, run this a few times @@ -246,7 +249,7 @@ def test_multiple_filter_options_shorthand(self): matching_workbooks = self.server.workbooks.filter(tags__in=["sample", "safari", "weather"], name="foo") self.assertEqual(3, matching_workbooks.total_available) - def test_slicing_queryset(self): + def test_slicing_queryset(self) -> None: with open(SLICING_QUERYSET, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: @@ -270,6 +273,16 @@ def test_slicing_queryset(self): with self.assertRaises(IndexError): all_views[100] - def test_queryset_filter_args_error(self): + def test_slicing_queryset_multi_page(self) -> None: + with requests_mock.mock() as m: + m.get(self.baseurl + "/views?pageNumber=1", text=SLICING_QUERYSET_PAGE_1.read_text()) + m.get(self.baseurl + "/views?pageNumber=2", text=SLICING_QUERYSET_PAGE_2.read_text()) + sliced_views = self.server.views.all()[9:12] + + self.assertEqual(sliced_views[0].id, "2e6d6c81-da71-4b41-892c-ba80d4e7a6d0") + self.assertEqual(sliced_views[1].id, "47ffcb8e-3f7a-4ecf-8ab3-605da9febe20") + self.assertEqual(sliced_views[2].id, "6757fea8-0aa9-4160-a87c-9be27b1d1c8c") + + def test_queryset_filter_args_error(self) -> None: with self.assertRaises(RuntimeError): workbooks = self.server.workbooks.filter("argument") diff --git a/test/test_user.py b/test/test_user.py index 6ba8ff7f2..b8fe32388 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -111,6 +111,16 @@ def test_remove(self) -> None: m.delete(self.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794", status_code=204) self.server.users.remove("dd2239f6-ddf1-4107-981a-4cf94e415794") + def test_remove_with_replacement(self) -> None: + with requests_mock.mock() as m: + m.delete( + self.baseurl + + "/dd2239f6-ddf1-4107-981a-4cf94e415794" + + "?mapAssetsTo=4cc4c17f-898a-4de4-abed-a1681c673ced", + status_code=204, + ) + self.server.users.remove("dd2239f6-ddf1-4107-981a-4cf94e415794", "4cc4c17f-898a-4de4-abed-a1681c673ced") + def test_remove_missing_id(self) -> None: self.assertRaises(ValueError, self.server.users.remove, "") diff --git a/test/test_workbook_model.py b/test/test_workbook_model.py index d45899e2d..fc6423564 100644 --- a/test/test_workbook_model.py +++ b/test/test_workbook_model.py @@ -4,12 +4,6 @@ class WorkbookModelTests(unittest.TestCase): - def test_invalid_project_id(self): - self.assertRaises(ValueError, TSC.WorkbookItem, None) - workbook = TSC.WorkbookItem("10") - with self.assertRaises(ValueError): - workbook.project_id = None - def test_invalid_show_tabs(self): workbook = TSC.WorkbookItem("10") with self.assertRaises(ValueError): From 265a4bf6735d47d3bb487283543a0628a7505900 Mon Sep 17 00:00:00 2001 From: Tim <50115603+bossenti@users.noreply.github.com> Date: Sat, 10 Sep 2022 08:26:15 +0200 Subject: [PATCH 011/296] replace deprecated Version class from distutils with packaging.version (#1105) --- tableauserverclient/server/server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 4522bc272..e35514474 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -1,7 +1,7 @@ -from distutils.version import LooseVersion as Version import urllib3 import requests from defusedxml.ElementTree import fromstring +from packaging.version import Version from .endpoint import ( Sites, @@ -38,7 +38,7 @@ import requests -from distutils.version import LooseVersion as Version +from packaging.version import Version _PRODUCT_TO_REST_VERSION = { "10.0": "2.3", From 3c021c084f38fd9cf619a583f7ba586f4bb3b219 Mon Sep 17 00:00:00 2001 From: Jac Date: Mon, 12 Sep 2022 15:14:44 -0700 Subject: [PATCH 012/296] prepare release 0.20 (#1107) * Datasources: Use explicit None identity check for datasource updates (#1099) (Resolves #1062 - cannot set empty password) * Projects: add publish-samples option to create/update project * Workbooks: fix workbook.delete_extract, add workbook pdf download, make project_id nullable to support "Personal Space", Remove vf support from populate_excel, make hidden views an attribute of Workbooks and deprecate hidden_views flag in publish request * Schedules: add get_by_id method * Users: Reassign content on user removal, add user import logic * Jobs: Add Status, ParentProjectId and StartedAt filters, Extract refreshable item IDs from job XML response * Sites: Add version awareness to site create/update methods: Update sites requests for Breaking change in 3.10: flowsEnabled removed, flowsEditingEnabled and flowsSchedulingEnabled added ,Allow setting site user_quota to None if tiered licenses exist * Do not eagerly fetch content when a stream was requested * create single Credentials class (#1032), Included redacted print methods for each credential type * on init set use_server_version = False so that we don't try and contact the server before people finish setting certs * add client version/debug header * Logging: log RequestOptions params (#1070), add redaction method to remove passwords when logging requests and responses, which can contain embedded credentials, log the url of the request that got an error in the response. * fix filter for python 3, remove support for python 3.6 (add python version enforcement in setup.py) * Fix slicing logic, add tests for queryset slicing crossing a page, add support for len magic method to queryset * Add type hints for workbook and data source revisions, data alerts, Favorites, Flows, groups, permissions, projects, sites, subscriptions, Users, webhooks * Samples: fix export sample, delete redundant samples (export_wb, download_view_image), add user import sample, default permissions sample * add publish to pypi actio, enable Black for CI, consolidate config files into pyproject.toml co-authored-by: Amar Yadav Co-authored-by: Jac Co-authored-by: Stephen Mitchell Co-authored-by: jorwoods Co-authored-by: Brian Cantoni Co-authored-by: Tyler Doyle Co-authored-by: bcmyguest1 <49045013+bcmyguest1@users.noreply.github.com> --- .github/workflows/publish-pypi.yml | 9 +- .github/workflows/run-tests.yml | 4 - MANIFEST.in | 12 +- contributing.md | 5 +- pyproject.toml | 42 +++- samples/create_group.py | 48 +++- samples/explore_site.py | 83 +++++++ samples/list.py | 5 +- samples/online_users.csv | 2 + setup.cfg | 19 +- setup.py | 49 +--- smoke/__init__.py | 0 tableauserverclient/__init__.py | 66 +++-- tableauserverclient/datetime_helpers.py | 4 +- tableauserverclient/models/connection_item.py | 46 ++-- tableauserverclient/models/group_item.py | 8 +- tableauserverclient/models/project_item.py | 3 - tableauserverclient/models/site_item.py | 32 ++- tableauserverclient/models/user_item.py | 180 +++++++++++++- tableauserverclient/server/__init__.py | 2 +- .../server/endpoint/auth_endpoint.py | 4 +- .../server/endpoint/endpoint.py | 28 ++- .../server/endpoint/exceptions.py | 50 ++-- .../server/endpoint/jobs_endpoint.py | 2 +- .../server/endpoint/server_info_endpoint.py | 1 + .../server/endpoint/sites_endpoint.py | 30 ++- .../server/endpoint/tasks_endpoint.py | 4 +- .../server/endpoint/users_endpoint.py | 56 ++++- .../server/endpoint/views_endpoint.py | 12 +- tableauserverclient/server/query.py | 23 +- tableauserverclient/server/request_factory.py | 76 ++++-- tableauserverclient/server/request_options.py | 27 +++ tableauserverclient/server/server.py | 39 ++- test/assets/Data/user_details.csv | 1 + test/assets/Data/usernames.csv | 7 + test/assets/site_get_by_id.xml | 4 +- test/assets/site_get_by_name.xml | 4 +- test/assets/site_update.xml | 4 +- test/test_group.py | 10 +- test/test_project.py | 7 +- test/test_requests.py | 1 + test/test_site.py | 18 +- test/test_user.py | 24 ++ test/test_user_model.py | 114 +++++++++ versioneer.py | 226 ++++++++++-------- 45 files changed, 1005 insertions(+), 386 deletions(-) create mode 100644 samples/explore_site.py create mode 100644 samples/online_users.csv delete mode 100644 smoke/__init__.py create mode 100644 test/assets/Data/user_details.csv create mode 100644 test/assets/Data/usernames.csv diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 2b3b8fa3e..9b4e842ee 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -6,8 +6,8 @@ name: Publish to PyPi on: workflow_dispatch: push: - branches: - - master + tags: + - 'v*.*.*' jobs: build-n-publish: @@ -19,12 +19,13 @@ jobs: fetch-depth: 0 - uses: actions/setup-python@v1 with: - python-version: 3.8 + python-version: 3.7 - name: Build dist files run: | python -m pip install --upgrade pip pip install -e .[test] - python setup.py sdist --formats=gztar + python setup.py sdist --formats=gztar bdist_wheel + git describe --tag --dirty --always - name: Publish distribution 📦 to Test PyPI uses: pypa/gh-action-pypi-publish@release/v1 # license BSD-2 with: diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 60a209b61..b83af5a4b 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -29,7 +29,3 @@ jobs: if: always() run: | pytest test - - - name: Run Mypy tests - run: | - mypy --show-error-codes --disable-error-code misc --disable-error-code import tableauserverclient test diff --git a/MANIFEST.in b/MANIFEST.in index c9bb30ee7..9b7512fb9 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,13 +1,14 @@ -include versioneer.py -include tableauserverclient/_version.py +include CHANGELOG.md +include contributing.md +include CONTRIBUTORS.md include LICENSE include LICENSE.versioneer include README.md -include CHANGELOG.md +include tableauserverclient/_version.py +include versioneer.py recursive-include docs *.md recursive-include samples *.py recursive-include samples *.txt -recursive-include smoke *.py recursive-include test *.csv recursive-include test *.dict recursive-include test *.hyper @@ -16,5 +17,6 @@ recursive-include test *.pdf recursive-include test *.png recursive-include test *.py recursive-include test *.xml +recursive-include test *.tde global-include *.pyi -global-include *.typed \ No newline at end of file +global-include *.typed diff --git a/contributing.md b/contributing.md index c5f0fa95e..90fbdc4f0 100644 --- a/contributing.md +++ b/contributing.md @@ -57,9 +57,8 @@ somewhere. ## Getting Started ```shell -pip install versioneer -python setup.py build -python setup.py test +python -m build +pytest ``` ### To use your locally built version diff --git a/pyproject.toml b/pyproject.toml index 1884a6b37..840c062e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,37 @@ [build-system] -requires = ["setuptools>=45.0", "versioneer-518", "wheel"] +requires = ["setuptools>=45.0", "versioneer>=0.24", "wheel"] build-backend = "setuptools.build_meta" +[project] +name="tableauserverclient" + +dynamic = ["version"] +description='A Python module for working with the Tableau Server REST API.' +authors = [{name="Tableau", email="github@tableau.com"}] +license = {file = "LICENSE"} +readme = "README.md" + +dependencies = [ + 'defusedxml>=0.7.1', + 'packaging~=21.3', + 'requests>=2.28', + 'urllib3~=1.26.8', +] +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" +] +[project.urls] +repository = "https://github.com/tableau/server-client-python" + +[project.optional-dependencies] +test = ["argparse", "black", "mock", "mypy", "pytest>=7.0", "requests-mock>=1.0,<2.0"] + [tool.black] line-length = 120 target-version = ['py37', 'py38', 'py39', 'py310'] @@ -11,8 +41,10 @@ disable_error_code = [ 'misc', 'import' ] -files = [ - "tableauserverclient", - "test" -] +files = ["tableauserverclient", "test"] show_error_codes = true +ignore_missing_imports = true + +[tool.pytest.ini_options] +testpaths = ["test"] +addopts = "--junitxml=./test.junit.xml" diff --git a/samples/create_group.py b/samples/create_group.py index 3875ffea5..50d84a187 100644 --- a/samples/create_group.py +++ b/samples/create_group.py @@ -8,10 +8,13 @@ import argparse import logging +import os from datetime import time +from typing import List import tableauserverclient as TSC +from tableauserverclient import ServerResponseError def main(): @@ -35,7 +38,7 @@ def main(): ) # Options specific to this sample # This sample has no additional options, yet. If you add some, please add them here - + parser.add_argument("--file", help="csv file containing user info", required=False) args = parser.parse_args() # Set logging level based on user input, or error by default @@ -45,9 +48,48 @@ def main(): tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): + # this code shows 3 different error codes that mean "resource is already in collection" + # 409009: group already exists on server + # 409107: user is already on site + # 409011: user is already in group + group = TSC.GroupItem("test") - group = server.groups.create(group) - print(group) + try: + group = server.groups.create(group) + except TSC.server.endpoint.exceptions.ServerResponseError as rError: + if rError.code == "409009": + print("Group already exists") + group = server.groups.filter(name=group.name)[0] + else: + raise rError + server.groups.populate_users(group) + for user in group.users: + print(user.name) + + if args.file: + filepath = os.path.abspath(args.file) + print("Add users to site from file {}:".format(filepath)) + added: List[TSC.UserItem] + failed: List[TSC.UserItem, TSC.ServerResponseError] + added, failed = server.users.create_from_file(filepath) + for user, error in failed: + print(user, error.code) + if error.code == "409017": + user = server.users.filter(name=user.name)[0] + added.append(user) + print("Adding users to group:{}".format(added)) + for user in added: + print("Adding user {}".format(user)) + try: + server.groups.add_user(group, user.id) + except ServerResponseError as serverError: + if serverError.code == "409011": + print("user {} is already a member of group {}".format(user.name, group.name)) + else: + raise rError + + for user in group.users: + print(user.name) if __name__ == "__main__": diff --git a/samples/explore_site.py b/samples/explore_site.py new file mode 100644 index 000000000..8c4abd9d3 --- /dev/null +++ b/samples/explore_site.py @@ -0,0 +1,83 @@ +#### +# This script demonstrates how to use the Tableau Server Client +# to interact with sites. +#### + +import argparse +import logging +import os.path +import sys + +import tableauserverclient as TSC + + +def main(): + + parser = argparse.ArgumentParser(description="Explore site updates by the Server API.") + # Common options; please keep those in sync across all samples + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, 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)", + ) + + parser.add_argument("--delete") + parser.add_argument("--create") + parser.add_argument("--url") + parser.add_argument("--new_site_name") + parser.add_argument("--user_quota") + parser.add_argument("--storage_quota") + parser.add_argument("--status") + + 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) + + # SIGN IN + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=True) + new_site = None + with server.auth.sign_in(tableau_auth): + current_site = server.sites.get_by_id(server.site_id) + + if args.delete: + print("You can only delete the site you are currently in") + print("Delete site `{}`?".format(current_site.name)) + # server.sites.delete(server.site_id) + + elif args.create: + new_site = TSC.SiteItem(args.create, args.url or args.create) + site_item = server.sites.create(new_site) + print(site_item) + # to do anything further with the site, you need to log into it + # if a PAT is required, that means going to the UI to create one + + else: + new_site = current_site + print(current_site, "current user quota:", current_site.user_quota) + print("Remember, you can only update the site you are currently in") + if args.url: + new_site.content_url = args.url + if args.user_quota: + new_site.user_quota = args.user_quota + try: + updated_site = server.sites.update(new_site) + print(updated_site, "new user quota:", updated_site.user_quota) + except TSC.ServerResponseError as e: + print(e) + + +if __name__ == "__main__": + main() diff --git a/samples/list.py b/samples/list.py index 814c1b9ca..b5cdb38a5 100644 --- a/samples/list.py +++ b/samples/list.py @@ -59,7 +59,10 @@ def main(): count = 0 for resource in TSC.Pager(endpoint.get, options): count = count + 1 - print(resource.id, resource.name) + # endpoint.populate_connections(resource) + print(resource.name[:18], " ") # , resource._connections()) + if count > 100: + break print("Total: {}".format(count)) diff --git a/samples/online_users.csv b/samples/online_users.csv new file mode 100644 index 000000000..bf4843679 --- /dev/null +++ b/samples/online_users.csv @@ -0,0 +1,2 @@ +ayoung@tableau.com, , , "Creator", None, Yes +ahsiao@tableau.com, , , "Explorer", None, No diff --git a/setup.cfg b/setup.cfg index dafb578b7..a551fdb6a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,27 +1,10 @@ -[wheel] -universal = 1 - -[pep8] -max_line_length = 120 - # See the docstring in versioneer.py for instructions. Note that you must # re-run 'versioneer.py setup' after changing this section, and commit the # resulting files. - +# versioneer does not support pyproject.toml [versioneer] VCS = git style = pep440-pre versionfile_source = tableauserverclient/_version.py versionfile_build = tableauserverclient/_version.py tag_prefix = v -#parentdir_prefix = - -[aliases] -smoke=pytest - -[tool:pytest] -testpaths = test smoke -addopts = --junitxml=./test.junit.xml - -[mypy] -ignore_missing_imports = True diff --git a/setup.py b/setup.py index 24d35250c..60d8fe6b8 100644 --- a/setup.py +++ b/setup.py @@ -1,49 +1,22 @@ -import sys import versioneer +from setuptools import setup -try: - from setuptools import setup -except ImportError: - from distutils.core import setup - -from os import path -this_directory = path.abspath(path.dirname(__file__)) -with open(path.join(this_directory, 'README.md'), encoding='utf-8') as f: - long_description = f.read() - -# Only install pytest and runner when test command is run -# This makes work easier for offline installs or low bandwidth machines -needs_pytest = {'pytest', 'test', 'ptr'}.intersection(sys.argv) -pytest_runner = ['pytest-runner'] if needs_pytest else [] -test_requirements = ['black', 'mock', 'pytest', 'requests-mock>=1.0,<2.0', 'mypy>=0.920'] - +""" +once versioneer 0.25 gets released, we can move this from setup.cfg to pyproject.toml +[tool.versioneer] +VCS = "git" +style = "pep440-pre" +versionfile_source = "tableauserverclient/_version.py" +versionfile_build = "tableauserverclient/_version.py" +tag_prefix = "v" +""" setup( - name='tableauserverclient', version=versioneer.get_version(), cmdclass=versioneer.get_cmdclass(), - author='Tableau', - author_email='github@tableau.com', - url='https://github.com/tableau/server-client-python', - package_data={'tableauserverclient':['py.typed']}, + # not yet sure how to move this to pyproject.toml packages=['tableauserverclient', 'tableauserverclient.helpers', 'tableauserverclient.models', 'tableauserverclient.server', 'tableauserverclient.server.endpoint'], - license='MIT', - description='A Python module for working with the Tableau Server REST API.', - long_description=long_description, - long_description_content_type='text/markdown', - test_suite='test', - setup_requires=pytest_runner, - install_requires=[ - 'defusedxml>=0.7.1', - 'requests>=2.11,<3.0', - ], - python_requires='>3.7.0', - tests_require=test_requirements, - extras_require={ - 'test': test_requirements - }, - zip_safe=False ) diff --git a/smoke/__init__.py b/smoke/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 592551b4e..394184120 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -1,62 +1,52 @@ -from ._version import get_versions +from .namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE from .models import ( + BackgroundJobItem, + ColumnItem, ConnectionCredentials, ConnectionItem, + DQWItem, + DailyInterval, DataAlertItem, + DatabaseItem, DatasourceItem, - DQWItem, + FlowItem, + FlowRunItem, GroupItem, + HourlyInterval, + IntervalItem, JobItem, - BackgroundJobItem, + MetricItem, + MonthlyInterval, PaginationItem, + Permission, + PermissionsRule, + PersonalAccessTokenAuth, ProjectItem, + RevisionItem, ScheduleItem, SiteItem, + SubscriptionItem, + TableItem, TableauAuth, - PersonalAccessTokenAuth, + Target, + TaskItem, + UnpopulatedPropertyError, UserItem, ViewItem, - WorkbookItem, - UnpopulatedPropertyError, - HourlyInterval, - DailyInterval, - WeeklyInterval, - MonthlyInterval, - IntervalItem, - TaskItem, - SubscriptionItem, - Target, - PermissionsRule, - Permission, - DatabaseItem, - TableItem, - ColumnItem, - FlowItem, WebhookItem, - PersonalAccessTokenAuth, - FlowRunItem, - RevisionItem, - MetricItem, - TableauItem, - Resource, - plural_type, + WeeklyInterval, + WorkbookItem, ) -from .namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE from .server import ( - RequestOptions, CSVRequestOptions, ImageRequestOptions, PDFRequestOptions, - Filter, - Sort, - Server, - ServerResponseError, + RequestOptions, MissingRequiredFieldError, NotSignedInError, + ServerResponseError, + Filter, Pager, + Server, + Sort, ) -from .helpers import * - -__version__ = get_versions()["version"] -__VERSION__ = __version__ -del get_versions diff --git a/tableauserverclient/datetime_helpers.py b/tableauserverclient/datetime_helpers.py index 2b1df202c..0d968428d 100644 --- a/tableauserverclient/datetime_helpers.py +++ b/tableauserverclient/datetime_helpers.py @@ -1,12 +1,12 @@ import datetime -# This code below is from the python documentation for -# tzinfo: https://docs.python.org/2.3/lib/datetime-tzinfo.html ZERO = datetime.timedelta(0) HOUR = datetime.timedelta(hours=1) +# This class is a concrete implementation of the abstract base class tzinfo +# docs: https://docs.python.org/2.3/lib/datetime-tzinfo.html class UTC(datetime.tzinfo): """UTC""" diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 17ca20bb9..ed7733076 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -1,44 +1,48 @@ +from typing import TYPE_CHECKING, List, Optional from defusedxml.ElementTree import fromstring from .connection_credentials import ConnectionCredentials +if TYPE_CHECKING: + from tableauserverclient.models.connection_credentials import ConnectionCredentials + class ConnectionItem(object): def __init__(self): - self._datasource_id = None - self._datasource_name = None - self._id = None - self._connection_type = None - self.embed_password = None - self.password = None - self.server_address = None - self.server_port = None - self.username = None - self.connection_credentials = None + self._datasource_id: Optional[str] = None + self._datasource_name: Optional[str] = None + self._id: Optional[str] = None + self._connection_type: Optional[str] = None + self.embed_password: bool = None + self.password: Optional[str] = None + self.server_address: Optional[str] = None + self.server_port: Optional[str] = None + self.username: Optional[str] = None + self.connection_credentials: Optional["ConnectionCredentials"] = None @property - def datasource_id(self): + def datasource_id(self) -> Optional[str]: return self._datasource_id @property - def datasource_name(self): + def datasource_name(self) -> Optional[str]: return self._datasource_name @property - def id(self): + def id(self) -> Optional[str]: return self._id @property - def connection_type(self): + def connection_type(self) -> Optional[str]: return self._connection_type def __repr__(self): - return "".format( + return "".format( **self.__dict__ ) @classmethod - def from_response(cls, resp, ns): + def from_response(cls, resp, ns) -> List["ConnectionItem"]: all_connection_items = list() parsed_response = fromstring(resp) all_connection_xml = parsed_response.findall(".//t:connection", namespaces=ns) @@ -58,7 +62,7 @@ def from_response(cls, resp, ns): return all_connection_items @classmethod - def from_xml_element(cls, parsed_response, ns): + def from_xml_element(cls, parsed_response, ns) -> List["ConnectionItem"]: """ @@ -69,7 +73,7 @@ def from_xml_element(cls, parsed_response, ns): """ - all_connection_items = list() + all_connection_items: List["ConnectionItem"] = list() all_connection_xml = parsed_response.findall(".//t:connection", namespaces=ns) for connection_xml in all_connection_xml: @@ -82,11 +86,13 @@ def from_xml_element(cls, parsed_response, ns): if connection_credentials is not None: - connection_item.connection_credentials = ConnectionCredentials.from_xml_element(connection_credentials) + connection_item.connection_credentials = ConnectionCredentials.from_xml_element( + connection_credentials, ns + ) return all_connection_items # Used to convert string represented boolean to a boolean type -def string_to_bool(s): +def string_to_bool(s: str) -> bool: return s.lower() == "true" diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index 6fcf18544..eb03b1b5d 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -27,6 +27,11 @@ def __init__(self, name=None, domain_name=None) -> None: self.name: Optional[str] = name self.domain_name: Optional[str] = domain_name + def __str__(self): + return "{}({!r})".format(self.__class__.__name__, self.__dict__) + + __repr__ = __str__ + @property def domain_name(self) -> Optional[str]: return self._domain_name @@ -74,9 +79,6 @@ def users(self) -> "Pager": # Each call to `.users` should create a new pager, this just runs the callable return self._users() - def to_reference(self) -> ResourceReference: - return ResourceReference(id_=self.id, tag_name=self.tag_name) - def _set_users(self, users: Callable[..., "Pager"]) -> None: self._users = users diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 9237d134e..acb14ce91 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -9,9 +9,6 @@ from typing import List, Optional -from typing import List, Optional, TYPE_CHECKING - - class ProjectItem(object): class ContentPermissions: LockedToProject: str = "LockedToProject" diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index 2d27acabf..3deda03e2 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -1,8 +1,8 @@ import warnings import xml.etree.ElementTree as ET +from distutils.version import Version from defusedxml.ElementTree import fromstring - from .property_decorators import ( property_is_enum, property_is_boolean, @@ -14,7 +14,10 @@ VALID_CONTENT_URL_RE = r"^[a-zA-Z0-9_\-]*$" -from typing import List, Optional, Union +from typing import List, Optional, Union, TYPE_CHECKING + +if TYPE_CHECKING: + from tableauserverclient.server import Server class SiteItem(object): @@ -23,6 +26,19 @@ class SiteItem(object): _tier_explorer_capacity: Optional[int] = None _tier_viewer_capacity: Optional[int] = None + def __str__(self): + return ( + "<" + + __name__ + + ": " + + (self.name or "unnamed") + + ", " + + (self.id or "unknown-id") + + ", " + + (self.state or "unknown-state") + + ">" + ) + class AdminMode: ContentAndUsers: str = "ContentAndUsers" ContentOnly: str = "ContentOnly" @@ -261,6 +277,13 @@ def cataloging_enabled(self) -> bool: def cataloging_enabled(self, value: bool): self._cataloging_enabled = value + def is_default(self) -> bool: + return self.name.lower() == "default" + + @staticmethod + def use_new_flow_settings(parent_srv: "Server") -> bool: + return parent_srv is not None and parent_srv.check_at_least_version("3.10") + @property def flows_enabled(self) -> bool: return self._flows_enabled @@ -268,11 +291,10 @@ def flows_enabled(self) -> bool: @flows_enabled.setter @property_is_boolean def flows_enabled(self, value: bool) -> None: + # Flows Enabled' is not a supported site setting in API Version [3.17]. + # In Version 3.10+ use the more granular settings 'Editing Flows Enabled' and/or 'Scheduling Flows Enabled' self._flows_enabled = value - def is_default(self) -> bool: - return self.name.lower() == "default" - @property def editing_flows_enabled(self) -> bool: return self._editing_flows_enabled diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index f60e72951..032841dc7 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -1,7 +1,8 @@ -from datetime import datetime +import io +import logging import xml.etree.ElementTree as ET from datetime import datetime -from typing import Dict, List, Optional, TYPE_CHECKING +from enum import IntEnum from defusedxml.ElementTree import fromstring @@ -9,15 +10,11 @@ from .property_decorators import ( property_is_enum, property_not_empty, - property_not_nullable, ) from .reference_item import ResourceReference from ..datetime_helpers import parse_datetime -if TYPE_CHECKING: - from ..server.pager import Pager - -from typing import Dict, List, Optional, TYPE_CHECKING +from typing import Dict, List, Optional, TYPE_CHECKING, Tuple if TYPE_CHECKING: from ..server.pager import Pager @@ -72,6 +69,10 @@ def __init__( return None + def __repr__(self) -> str: + str_site_role = self.site_role or "None" + return "".format(self.id, self.name, str_site_role) + @property def auth_setting(self) -> Optional[str]: return self._auth_setting @@ -106,12 +107,24 @@ def name(self) -> Optional[str]: def name(self, value: str): self._name = value + # valid: username, domain/username, username@domain, domain/username@email + @staticmethod + def validate_username_or_throw(username) -> None: + if username is None or username == "" or username.strip(" ") == "": + raise AttributeError("Username cannot be empty") + if username.find(" ") >= 0: + raise AttributeError("Username cannot contain spaces") + at_symbol = username.find("@") + if at_symbol >= 0: + username = username[:at_symbol] + "X" + username[at_symbol + 1 :] + if username.find("@") >= 0: + raise AttributeError("Username cannot repeat '@'") + @property def site_role(self) -> Optional[str]: return self._site_role @site_role.setter - @property_not_nullable @property_is_enum(Roles) def site_role(self, value): self._site_role = value @@ -137,9 +150,6 @@ def groups(self) -> "Pager": raise UnpopulatedPropertyError(error) return self._groups() - def to_reference(self) -> ResourceReference: - return ResourceReference(id_=self.id, tag_name=self.tag_name) - def _set_workbooks(self, workbooks) -> None: self._workbooks = workbooks @@ -259,5 +269,149 @@ def _parse_element(user_xml, ns): domain_name, ) - def __repr__(self) -> str: - return "".format(self.id, self.name, self.site_role) + class CSVImport(object): + """ + This class includes hardcoded options and logic for the CSV file format defined for user import + https://help.tableau.com/current/server/en-us/users_import.htm + """ + + # username, password, display_name, license, admin_level, publishing, email, auth type + class ColumnType(IntEnum): + USERNAME = 0 + PASS = 1 + DISPLAY_NAME = 2 + LICENSE = 3 # aka site role + ADMIN = 4 + PUBLISHER = 5 + EMAIL = 6 + AUTH = 7 + + MAX = 7 + + # Read a csv line and create a user item populated by the given attributes + @staticmethod + def create_user_from_line(line: str): + if line is None or line is False or line == "\n" or line == "": + return None + line = line.strip().lower() + values: List[str] = list(map(str.strip, line.split(","))) + user = UserItem(values[UserItem.CSVImport.ColumnType.USERNAME]) + if len(values) > 1: + if len(values) > UserItem.CSVImport.ColumnType.MAX: + raise ValueError("Too many attributes for user import") + while len(values) <= UserItem.CSVImport.ColumnType.MAX: + values.append("") + site_role = UserItem.CSVImport._evaluate_site_role( + values[UserItem.CSVImport.ColumnType.LICENSE], + values[UserItem.CSVImport.ColumnType.ADMIN], + values[UserItem.CSVImport.ColumnType.PUBLISHER], + ) + + user._set_values( + None, + values[UserItem.CSVImport.ColumnType.USERNAME], + site_role, + None, + None, + values[UserItem.CSVImport.ColumnType.DISPLAY_NAME], + values[UserItem.CSVImport.ColumnType.EMAIL], + values[UserItem.CSVImport.ColumnType.AUTH], + None, + ) + return user + + # Read through an entire CSV file meant for user import + # Return the number of valid lines and a list of all the invalid lines + @staticmethod + def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> Tuple[int, List[str]]: + num_valid_lines = 0 + invalid_lines = [] + csv_file.seek(0) # set to start of file in case it has been read earlier + line: str = csv_file.readline() + while line and line != "": + try: + # do not print passwords + logger.info("Reading user {}".format(line[:4])) + UserItem.CSVImport._validate_import_line_or_throw(line, logger) + num_valid_lines += 1 + except Exception as exc: + logger.info("Error parsing {}: {}".format(line[:4], exc)) + invalid_lines.append(line) + line = csv_file.readline() + return num_valid_lines, invalid_lines + + # Some fields in the import file are restricted to specific values + # Iterate through each field and validate the given value against hardcoded constraints + @staticmethod + def _validate_import_line_or_throw(incoming, logger) -> None: + _valid_attributes: List[List[str]] = [ + [], + [], + [], + ["creator", "explorer", "viewer", "unlicensed"], # license + ["system", "site", "none", "no"], # admin + ["yes", "true", "1", "no", "false", "0"], # publisher + [], + [UserItem.Auth.SAML, UserItem.Auth.OpenID, UserItem.Auth.ServerDefault], # auth + ] + + line = list(map(str.strip, incoming.split(","))) + if len(line) > UserItem.CSVImport.ColumnType.MAX: + raise AttributeError("Too many attributes in line") + username = line[UserItem.CSVImport.ColumnType.USERNAME.value] + logger.debug("> details - {}".format(username)) + UserItem.validate_username_or_throw(username) + for i in range(1, len(line)): + logger.debug("column {}: {}".format(UserItem.CSVImport.ColumnType(i).name, line[i])) + UserItem.CSVImport._validate_attribute_value( + line[i], _valid_attributes[i], UserItem.CSVImport.ColumnType(i) + ) + + # Given a restricted set of possible values, confirm the item is in that set + @staticmethod + def _validate_attribute_value(item: str, possible_values: List[str], column_type) -> None: + if item is None or item == "": + # value can be empty for any column except user, which is checked elsewhere + return + if item in possible_values or possible_values == []: + return + raise AttributeError("Invalid value {} for {}".format(item, column_type)) + + # https://help.tableau.com/current/server/en-us/csvguidelines.htm#settings_and_site_roles + # This logic is hardcoded to match the existing rules for import csv files + @staticmethod + def _evaluate_site_role(license_level, admin_level, publisher): + if not license_level or not admin_level or not publisher: + return "Unlicensed" + # ignore case everywhere + license_level = license_level.lower() + admin_level = admin_level.lower() + publisher = publisher.lower() + # don't need to check publisher for system/site admin + if admin_level == "system": + site_role = "SiteAdministrator" + elif admin_level == "site": + if license_level == "creator": + site_role = "SiteAdministratorCreator" + elif license_level == "explorer": + site_role = "SiteAdministratorExplorer" + else: + site_role = "SiteAdministratorExplorer" + else: # if it wasn't 'system' or 'site' then we can treat it as 'none' + if publisher == "yes": + if license_level == "creator": + site_role = "Creator" + elif license_level == "explorer": + site_role = "ExplorerCanPublish" + else: + site_role = "Unlicensed" # is this the expected outcome? + else: # publisher == 'no': + if license_level == "explorer" or license_level == "creator": + site_role = "Explorer" + elif license_level == "viewer": + site_role = "Viewer" + else: # if license_level == 'unlicensed' + site_role = "Unlicensed" + if site_role is None: + site_role = "Unlicensed" + return site_role diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index cb680d914..25abb3c9a 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -9,7 +9,7 @@ from .filter import Filter from .sort import Sort -from .. import ( +from ..models import ( BackgroundJobItem, ColumnItem, ConnectionItem, diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 11e89975a..6baf399ed 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -30,7 +30,7 @@ def sign_in(self, auth_req): signin_req = RequestFactory.Auth.signin_req(auth_req) server_response = self.parent_srv.session.post(url, data=signin_req, **self.parent_srv.http_options) self.parent_srv._namespace.detect(server_response.content) - self._check_status(server_response) + self._check_status(server_response, url) parsed_response = fromstring(server_response.content) site_id = parsed_response.find(".//t:site", namespaces=self.parent_srv.namespace).get("id", None) user_id = parsed_response.find(".//t:user", namespaces=self.parent_srv.namespace).get("id", None) @@ -66,7 +66,7 @@ def switch_site(self, site_item): else: raise e self.parent_srv._namespace.detect(server_response.content) - self._check_status(server_response) + self._check_status(server_response, url) parsed_response = fromstring(server_response.content) site_id = parsed_response.find(".//t:site", namespaces=self.parent_srv.namespace).get("id", None) user_id = parsed_response.find(".//t:user", namespaces=self.parent_srv.namespace).get("id", None) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 0acc978d2..378c84746 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -11,6 +11,7 @@ NonXMLResponseError, EndpointUnavailableError, ) +from .. import endpoint from ..query import QuerySet from ... import helpers @@ -26,18 +27,29 @@ from requests import Response +_version_header: Optional[str] = None + + class Endpoint(object): def __init__(self, parent_srv: "Server"): + global _version_header self.parent_srv = parent_srv @staticmethod def _make_common_headers(auth_token, content_type): + global _version_header + + if not _version_header: + from ..server import __TSC_VERSION__ + + _version_header = __TSC_VERSION__ + headers = {} if auth_token is not None: headers["x-tableau-auth"] = auth_token if content_type is not None: headers["content-type"] = content_type - + headers["User-Agent"] = "Tableau Server Client/{}".format(_version_header) return headers def _make_request( @@ -63,7 +75,7 @@ def _make_request( logger.debug("request content: {}".format(helpers.strings.redact_xml(content[:1000]))) server_response = method(url, **parameters) - self._check_status(server_response) + self._check_status(server_response, url) loggable_response = self.log_response_safely(server_response) logger.debug("Server response from {0}:\n\t{1}".format(url, loggable_response)) @@ -73,13 +85,13 @@ def _make_request( return server_response - def _check_status(self, server_response): + def _check_status(self, server_response, url: str = None): if server_response.status_code >= 500: - raise InternalServerError(server_response) + raise InternalServerError(server_response, url) elif server_response.status_code not in Success_codes: # todo: is an error reliably of content-type application/xml? try: - raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace) + raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace, url) except ParseError: # This will happen if we get a non-success HTTP code that # doesn't return an xml error object (like metadata endpoints or 503 pages) @@ -112,7 +124,7 @@ def get_request(self, url, request_object=None, parameters=None): if request_object is not None: try: # Query param delimiters don't need to be encoded for versions before 3.7 (2020.1) - self.parent_srv.assert_at_least_version("3.7") + self.parent_srv.assert_at_least_version("3.7", "Query param encoding") parameters = parameters or {} parameters["params"] = request_object.get_query_params() except EndpointUnavailableError: @@ -126,7 +138,7 @@ def get_request(self, url, request_object=None, parameters=None): ) def delete_request(self, url): - # We don't return anything for a delete + # We don't return anything for a delete request self._make_request(self.parent_srv.session.delete, url, auth_token=self.parent_srv.auth_token) def put_request(self, url, xml_request=None, content_type=XML_CONTENT_TYPE, parameters=None): @@ -182,7 +194,7 @@ def api(version): def _decorator(func): @wraps(func) def wrapper(self, *args, **kwargs): - self.parent_srv.assert_at_least_version(version) + self.parent_srv.assert_at_least_version(version, "endpoint") return func(self, *args, **kwargs) return wrapper diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 34de00dd0..3ce0d5e92 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -1,62 +1,72 @@ from defusedxml.ElementTree import fromstring -class ServerResponseError(Exception): - def __init__(self, code, summary, detail): +class TableauError(Exception): + pass + + +class ServerResponseError(TableauError): + def __init__(self, code, summary, detail, url=None): self.code = code self.summary = summary self.detail = detail + self.url = url super(ServerResponseError, self).__init__(str(self)) def __str__(self): return "\n\n\t{0}: {1}\n\t\t{2}".format(self.code, self.summary, self.detail) @classmethod - def from_response(cls, resp, ns): + def from_response(cls, resp, ns, url=None): # Check elements exist before .text parsed_response = fromstring(resp) - error_response = cls( - parsed_response.find("t:error", namespaces=ns).get("code", ""), - parsed_response.find(".//t:summary", namespaces=ns).text, - parsed_response.find(".//t:detail", namespaces=ns).text, - ) + try: + error_response = cls( + parsed_response.find("t:error", namespaces=ns).get("code", ""), + parsed_response.find(".//t:summary", namespaces=ns).text, + parsed_response.find(".//t:detail", namespaces=ns).text, + url, + ) + except Exception as e: + raise NonXMLResponseError(resp) return error_response -class InternalServerError(Exception): - def __init__(self, server_response): +class InternalServerError(TableauError): + def __init__(self, server_response, request_url: str = None): self.code = server_response.status_code self.content = server_response.content + self.url = request_url or "server" def __str__(self): - return "\n\nError status code: {0}\n{1}".format(self.code, self.content) + return "\n\nInternal error {0} at {1}\n{2}".format(self.code, self.url, self.content) -class MissingRequiredFieldError(Exception): +class MissingRequiredFieldError(TableauError): pass -class ServerInfoEndpointNotFoundError(Exception): +class ServerInfoEndpointNotFoundError(TableauError): pass -class EndpointUnavailableError(Exception): +class EndpointUnavailableError(TableauError): pass -class ItemTypeNotAllowed(Exception): +class ItemTypeNotAllowed(TableauError): pass -class NonXMLResponseError(Exception): +class NonXMLResponseError(TableauError): pass -class InvalidGraphQLQuery(Exception): +class InvalidGraphQLQuery(TableauError): pass -class GraphQLError(Exception): +class GraphQLError(TableauError): def __init__(self, error_payload): self.error = error_payload @@ -66,7 +76,7 @@ def __str__(self): return pformat(self.error) -class JobFailedException(Exception): +class JobFailedException(TableauError): def __init__(self, job): self.notes = job.notes self.job = job @@ -79,7 +89,7 @@ class JobCancelledException(JobFailedException): pass -class FlowRunFailedException(Exception): +class FlowRunFailedException(TableauError): def __init__(self, flow_run): self.background_job_id = flow_run.background_job_id self.flow_run = flow_run diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index 99870ac34..6b709efad 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -29,7 +29,7 @@ def get( if isinstance(job_id, RequestOptionsBase): req_options = job_id - self.parent_srv.assert_at_least_version("3.1") + self.parent_srv.assert_at_least_version("3.1", "Jobs.get_by_id(job_id)") server_response = self.get_request(self.baseurl, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) jobs = BackgroundJobItem.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index 5c9461d1c..2036d8d5e 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -26,6 +26,7 @@ def get(self): raise ServerInfoEndpointNotFoundError if e.code == "404001": raise EndpointUnavailableError + raise e server_info = ServerInfoItem.from_response(server_response.content, self.parent_srv.namespace) return server_info diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index bdf281fb9..67d7db209 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -22,6 +22,7 @@ def baseurl(self) -> str: @api(version="2.0") def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[SiteItem], PaginationItem]: logger.info("Querying all sites on site") + logger.info("Requires Server Admin permissions") url = self.baseurl server_response = self.get_request(url, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) @@ -34,6 +35,10 @@ def get_by_id(self, site_id: str) -> SiteItem: if not site_id: error = "Site ID undefined." raise ValueError(error) + if not site_id == self.parent_srv.site_id: + error = "You can only retrieve the site for which you are currently authenticated." + raise ValueError(error) + logger.info("Querying single site (ID: {0})".format(site_id)) url = "{0}/{1}".format(self.baseurl, site_id) server_response = self.get_request(url) @@ -45,8 +50,10 @@ def get_by_name(self, site_name: str) -> SiteItem: if not site_name: error = "Site Name undefined." raise ValueError(error) + print("Note: You can only work with the site for which you are currently authenticated") logger.info("Querying single site (Name: {0})".format(site_name)) url = "{0}/{1}?key=name".format(self.baseurl, site_name) + print(self.baseurl, url) server_response = self.get_request(url) return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -56,7 +63,12 @@ def get_by_content_url(self, content_url: str) -> SiteItem: if content_url is None: error = "Content URL undefined." raise ValueError(error) + if not self.parent_srv.baseurl.index(content_url) > 0: + error = "You can only work with the site you are currently authenticated for" + raise ValueError(error) + logger.info("Querying single site (Content URL: {0})".format(content_url)) + logger.debug("Querying other sites requires Server Admin permissions") url = "{0}/{1}?key=contentUrl".format(self.baseurl, content_url) server_response = self.get_request(url) return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -67,13 +79,18 @@ def update(self, site_item: SiteItem) -> SiteItem: if not site_item.id: error = "Site item missing ID." raise MissingRequiredFieldError(error) + print(self.parent_srv.site_id, site_item.id) + if not site_item.id == self.parent_srv.site_id: + error = "You can only update the site you are currently authenticated for" + raise ValueError(error) + if site_item.admin_mode: if site_item.admin_mode == SiteItem.AdminMode.ContentOnly and site_item.user_quota: error = "You cannot set admin_mode to ContentOnly and also set a user quota" raise ValueError(error) url = "{0}/{1}".format(self.baseurl, site_item.id) - update_req = RequestFactory.Site.update_req(site_item) + update_req = RequestFactory.Site.update_req(site_item, self.parent_srv) server_response = self.put_request(url, update_req) logger.info("Updated site item (ID: {0})".format(site_item.id)) update_site = copy.copy(site_item) @@ -86,12 +103,11 @@ def delete(self, site_id: str) -> None: error = "Site ID undefined." raise ValueError(error) url = "{0}/{1}".format(self.baseurl, site_id) + if not site_id == self.parent_srv.site_id: + error = "You can only delete the site you are currently authenticated for" + raise ValueError(error) self.delete_request(url) - # If we deleted the site we are logged into - # then we are automatically logged out - if site_id == self.parent_srv.site_id: - logger.info("Deleting current site and clearing auth tokens") - self.parent_srv._clear_auth() + self.parent_srv._clear_auth() logger.info("Deleted single site (ID: {0}) and signed out".format(site_id)) # Create new site @@ -103,7 +119,7 @@ def create(self, site_item: SiteItem) -> SiteItem: raise ValueError(error) url = self.baseurl - create_req = RequestFactory.Site.create_req(site_item) + create_req = RequestFactory.Site.create_req(site_item, self.parent_srv) server_response = self.post_request(url, create_req) new_site = SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] logger.info("Created new site (ID: {0})".format(new_site.id)) diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index f147c79ae..a70480b91 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -25,7 +25,7 @@ def __normalize_task_type(self, task_type): @api(version="2.6") def get(self, req_options=None, task_type=TaskItem.Type.ExtractRefresh): if task_type == TaskItem.Type.DataAcceleration: - self.parent_srv.assert_at_least_version("3.8") + self.parent_srv.assert_at_least_version("3.8", "Data Acceleration Tasks") logger.info("Querying all {} tasks for the site".format(task_type)) @@ -69,7 +69,7 @@ def run(self, task_item): @api(version="3.6") def delete(self, task_id, task_type=TaskItem.Type.ExtractRefresh): if task_type == TaskItem.Type.DataAcceleration: - self.parent_srv.assert_at_least_version("3.8") + self.parent_srv.assert_at_least_version("3.8", "Data Acceleration Tasks") if not task_id: error = "No Task ID provided" diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 738364cd7..28406ab71 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -1,19 +1,16 @@ import copy import logging -from typing import List, Optional, Tuple +import os +from typing import List, Optional, Tuple, Union from .endpoint import QuerysetEndpoint, api -from .exceptions import MissingRequiredFieldError -from .. import ( - RequestFactory, - RequestOptions, - UserItem, - WorkbookItem, - PaginationItem, - GroupItem, -) +from .exceptions import MissingRequiredFieldError, ServerResponseError +from .. import RequestFactory, RequestOptions, UserItem, WorkbookItem, PaginationItem, GroupItem from ..pager import Pager +# duplicate defined in workbooks_endpoint +FilePath = Union[str, os.PathLike] + logger = logging.getLogger("tableau.endpoint.users") @@ -78,12 +75,51 @@ def remove(self, user_id: str, map_assets_to: Optional[str] = None) -> None: @api(version="2.0") def add(self, user_item: UserItem) -> UserItem: url = self.baseurl + logger.info("Add user {}".format(user_item.name)) add_req = RequestFactory.User.add_req(user_item) server_response = self.post_request(url, add_req) + logger.info(server_response) new_user = UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() logger.info("Added new user (ID: {0})".format(new_user.id)) return new_user + # Add new users to site. This does not actually perform a bulk action, it's syntactic sugar + @api(version="2.0") + def add_all(self, users: List[UserItem]): + created = [] + failed = [] + for user in users: + try: + result = self.add(user) + created.append(result) + except Exception as e: + failed.append(user) + return created, failed + + # helping the user by parsing a file they could have used to add users through the UI + # line format: Username [required], password, display name, license, admin, publish + @api(version="2.0") + def create_from_file(self, filepath: str) -> Tuple[List[UserItem], List[Tuple[UserItem, ServerResponseError]]]: + created = [] + failed = [] + if not filepath.find("csv"): + raise ValueError("Only csv files are accepted") + + with open(filepath) as csv_file: + csv_file.seek(0) # set to start of file in case it has been read earlier + line: str = csv_file.readline() + while line and line != "": + user: UserItem = UserItem.CSVImport.create_user_from_line(line) + try: + print(user) + result = self.add(user) + created.append(result) + except ServerResponseError as serverError: + print("failed") + failed.append((user, serverError)) + line = csv_file.readline() + return created, failed + # Get workbooks for user @api(version="2.0") def populate_workbooks(self, user_item: UserItem, req_options: RequestOptions = None) -> None: diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 67e66a81f..06cc08349 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -12,7 +12,13 @@ from typing import Iterator, List, Optional, Tuple, TYPE_CHECKING if TYPE_CHECKING: - from ..request_options import RequestOptions, CSVRequestOptions, PDFRequestOptions, ImageRequestOptions + from ..request_options import ( + RequestOptions, + CSVRequestOptions, + PDFRequestOptions, + ImageRequestOptions, + ExcelRequestOptions, + ) class Views(QuerysetEndpoint): @@ -126,7 +132,7 @@ def _get_view_csv(self, view_item: ViewItem, req_options: Optional["CSVRequestOp yield from server_response.iter_content(1024) @api(version="3.8") - def populate_excel(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"] = None) -> None: + def populate_excel(self, view_item: ViewItem, req_options: Optional["ExcelRequestOptions"] = None) -> None: if not view_item.id: error = "View item missing ID." raise MissingRequiredFieldError(error) @@ -137,7 +143,7 @@ def excel_fetcher(): view_item._set_excel(excel_fetcher) logger.info("Populated excel for view (ID: {0})".format(view_item.id)) - def _get_view_excel(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"]) -> Iterator[bytes]: + def _get_view_excel(self, view_item: ViewItem, req_options: Optional["ExcelRequestOptions"]) -> Iterator[bytes]: url = "{0}/{1}/crosstab/excel".format(self.baseurl, view_item.id) with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index 729447822..c5613b2d6 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -1,13 +1,21 @@ +from typing import Tuple from .filter import Filter from .request_options import RequestOptions from .sort import Sort import math -def to_camel_case(word): +def to_camel_case(word: str) -> str: return word.split("_")[0] + "".join(x.capitalize() or "_" for x in word.split("_")[1:]) +""" +This interface allows more fluent queries against Tableau Server +e.g server.users.get(name="user@domain.com") +see pagination_sample +""" + + class QuerySet: def __init__(self, model): self.model = model @@ -85,18 +93,21 @@ def _fetch_all(self): if self._result_cache is None: self._result_cache, self._pagination_item = self.model.get(self.request_options) + def __len__(self) -> int: + return self.total_available + @property - def total_available(self): + def total_available(self) -> int: self._fetch_all() return self._pagination_item.total_available @property - def page_number(self): + def page_number(self) -> int: self._fetch_all() return self._pagination_item.page_number @property - def page_size(self): + def page_size(self) -> int: self._fetch_all() return self._pagination_item.page_size @@ -121,7 +132,7 @@ def paginate(self, **kwargs): self.request_options.pagesize = kwargs["page_size"] return self - def _parse_shorthand_filter(self, key): + def _parse_shorthand_filter(self, key: str) -> Tuple[str, str]: tokens = key.split("__", 1) if len(tokens) == 1: operator = RequestOptions.Operator.Equals @@ -135,7 +146,7 @@ def _parse_shorthand_filter(self, key): raise ValueError("Field name `{}` is not valid.".format(field)) return (field, operator) - def _parse_shorthand_sort(self, key): + def _parse_shorthand_sort(self, key: str) -> Tuple[str, str]: direction = RequestOptions.Direction.Asc if key.startswith("-"): direction = RequestOptions.Direction.Desc diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index fc00ca085..aad8ca074 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1,6 +1,6 @@ from os import name import xml.etree.ElementTree as ET -from typing import Any, Dict, List, Optional, Tuple, Iterable +from typing import Any, Dict, Iterable, List, Optional, Tuple, TYPE_CHECKING from requests.packages.urllib3.fields import RequestField from requests.packages.urllib3.filepost import encode_multipart_formdata @@ -16,8 +16,6 @@ from ..models import TaskItem, UserItem, GroupItem, PermissionsRule, FavoriteItem from ..models import WebhookItem -from typing import Any, Dict, List, Optional, TYPE_CHECKING, Tuple, Iterable - if TYPE_CHECKING: from ..models import SubscriptionItem from ..models import DataAlertItem @@ -25,6 +23,7 @@ from ..models import ConnectionItem from ..models import SiteItem from ..models import ProjectItem + from tableauserverclient.server import Server def _add_multipart(parts: Dict) -> Tuple[Any, str]: @@ -39,7 +38,7 @@ def _add_multipart(parts: Dict) -> Tuple[Any, str]: def _tsrequest_wrapped(func): - def wrapper(self, *args, **kwargs): + def wrapper(self, *args, **kwargs) -> bytes: xml_request = ET.Element("tsRequest") func(self, xml_request, *args, **kwargs) return ET.tostring(xml_request) @@ -556,7 +555,7 @@ def _add_to_req(self, id_: Optional[str], target_type: str, task_type: str = Tas """ if not isinstance(id_, str): - raise ValueError(f"id_ should be a string, reeceived: {type(id_)}") + raise ValueError(f"id_ should be a string, received: {type(id_)}") xml_request = ET.Element("tsRequest") task_element = ET.SubElement(xml_request, "task") task = ET.SubElement(task_element, task_type) @@ -576,7 +575,7 @@ def add_flow_req(self, id_: Optional[str], task_type: str = TaskItem.Type.RunFlo class SiteRequest(object): - def update_req(self, site_item: "SiteItem"): + def update_req(self, site_item: "SiteItem", parent_srv: "Server" = None): xml_request = ET.Element("tsRequest") site_element = ET.SubElement(xml_request, "site") if site_item.name: @@ -601,14 +600,15 @@ def update_req(self, site_item: "SiteItem"): site_element.attrib["revisionHistoryEnabled"] = str(site_item.revision_history_enabled).lower() if site_item.data_acceleration_mode is not None: site_element.attrib["dataAccelerationMode"] = str(site_item.data_acceleration_mode).lower() - if site_item.flows_enabled is not None: - site_element.attrib["flowsEnabled"] = str(site_item.flows_enabled).lower() if site_item.cataloging_enabled is not None: site_element.attrib["catalogingEnabled"] = str(site_item.cataloging_enabled).lower() - if site_item.editing_flows_enabled is not None: - site_element.attrib["editingFlowsEnabled"] = str(site_item.editing_flows_enabled).lower() - if site_item.scheduling_flows_enabled is not None: - site_element.attrib["schedulingFlowsEnabled"] = str(site_item.scheduling_flows_enabled).lower() + + flows_edit = str(site_item.editing_flows_enabled).lower() + flows_schedule = str(site_item.scheduling_flows_enabled).lower() + flows_all = str(site_item.flows_enabled).lower() + + self.set_versioned_flow_attributes(flows_all, flows_edit, flows_schedule, parent_srv, site_element, site_item) + if site_item.allow_subscription_attachments is not None: site_element.attrib["allowSubscriptionAttachments"] = str(site_item.allow_subscription_attachments).lower() if site_item.guest_access_enabled is not None: @@ -682,7 +682,8 @@ def update_req(self, site_item: "SiteItem"): return ET.tostring(xml_request) - def create_req(self, site_item: "SiteItem"): + # server: the site request model changes based on api version + def create_req(self, site_item: "SiteItem", parent_srv: "Server" = None): xml_request = ET.Element("tsRequest") site_element = ET.SubElement(xml_request, "site") site_element.attrib["name"] = site_item.name @@ -701,12 +702,13 @@ def create_req(self, site_item: "SiteItem"): site_element.attrib["revisionLimit"] = str(site_item.revision_limit) if site_item.data_acceleration_mode is not None: site_element.attrib["dataAccelerationMode"] = str(site_item.data_acceleration_mode).lower() - if site_item.flows_enabled is not None: - site_element.attrib["flowsEnabled"] = str(site_item.flows_enabled).lower() - if site_item.editing_flows_enabled is not None: - site_element.attrib["editingFlowsEnabled"] = str(site_item.editing_flows_enabled).lower() - if site_item.scheduling_flows_enabled is not None: - site_element.attrib["schedulingFlowsEnabled"] = str(site_item.scheduling_flows_enabled).lower() + + flows_edit = str(site_item.editing_flows_enabled).lower() + flows_schedule = str(site_item.scheduling_flows_enabled).lower() + flows_all = str(site_item.flows_enabled).lower() + + self.set_versioned_flow_attributes(flows_all, flows_edit, flows_schedule, parent_srv, site_element, site_item) + if site_item.allow_subscription_attachments is not None: site_element.attrib["allowSubscriptionAttachments"] = str(site_item.allow_subscription_attachments).lower() if site_item.guest_access_enabled is not None: @@ -784,6 +786,32 @@ def create_req(self, site_item: "SiteItem"): return ET.tostring(xml_request) + def set_versioned_flow_attributes(self, flows_all, flows_edit, flows_schedule, parent_srv, site_element, site_item): + if (not parent_srv) or SiteItem.use_new_flow_settings(parent_srv): + if site_item.flows_enabled is not None: + flows_edit = flows_edit or flows_all + flows_schedule = flows_schedule or flows_all + import warnings + + warnings.warn( + "FlowsEnabled has been removed and become two options:" + " SchedulingFlowsEnabled and EditingFlowsEnabled" + ) + if site_item.editing_flows_enabled is not None: + site_element.attrib["editingFlowsEnabled"] = flows_edit + if site_item.scheduling_flows_enabled is not None: + site_element.attrib["schedulingFlowsEnabled"] = flows_schedule + + else: + if site_item.flows_enabled is not None: + site_element.attrib["flowsEnabled"] = str(site_item.flows_enabled).lower() + if site_item.editing_flows_enabled is not None or site_item.scheduling_flows_enabled is not None: + flows_all = flows_all or flows_edit or flows_schedule + site_element.attrib["flowsEnabled"] = flows_all + import warnings + + warnings.warn("In version 3.10 and earlier there is only one option: FlowsEnabled") + class TableRequest(object): def update_req(self, table_item): @@ -971,15 +999,15 @@ def embedded_extract_req(self, xml_request, include_all=True, datasources=None): class Connection(object): @_tsrequest_wrapped - def update_req(self, xml_request, connection_item): + def update_req(self, xml_request: ET.Element, connection_item: "ConnectionItem") -> None: connection_element = ET.SubElement(xml_request, "connection") - if connection_item.server_address: + if connection_item.server_address is not None: connection_element.attrib["serverAddress"] = connection_item.server_address.lower() - if connection_item.server_port: + if connection_item.server_port is not None: connection_element.attrib["serverPort"] = str(connection_item.server_port) - if connection_item.username: + if connection_item.username is not None: connection_element.attrib["userName"] = connection_item.username - if connection_item.password: + if connection_item.password is not None: connection_element.attrib["password"] = connection_item.password if connection_item.embed_password is not None: connection_element.attrib["embedPassword"] = str(connection_item.embed_password).lower() diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 4462ba786..f4ed8fd3c 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,4 +1,7 @@ from ..models.property_decorators import property_is_int +import logging + +logger = logging.getLogger("tableau.request_options") class RequestOptionsBase(object): @@ -8,6 +11,8 @@ def apply_query_params(self, url): params = self.get_query_params() params_list = ["{}={}".format(k, v) for (k, v) in params.items()] + logger.debug("Applying options to request: <%s(%s)>", self.__class__.__name__, ",".join(params_list)) + if "?" in url: url, existing_params = url.split("?") params_list.append(existing_params) @@ -142,6 +147,28 @@ def get_query_params(self): return params +class ExcelRequestOptions(RequestOptionsBase): + def __init__(self, maxage: int = -1) -> None: + super().__init__() + self.max_age = maxage + + @property + def max_age(self) -> int: + return self._max_age + + @max_age.setter + @property_is_int(range=(0, 240), allowed=[-1]) + def max_age(self, value: int) -> None: + self._max_age = value + + def get_query_params(self): + params = {} + if self.max_age != -1: + params["maxAge"] = self.max_age + + return params + + class ImageRequestOptions(_FilterOptionsBase): # if 'high' isn't specified, the REST API endpoint returns an image with standard resolution class Resolution: diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index e35514474..c82f4a6e2 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -1,8 +1,8 @@ -import urllib3 import requests +import urllib3 + from defusedxml.ElementTree import fromstring from packaging.version import Version - from .endpoint import ( Sites, Views, @@ -30,15 +30,17 @@ Metrics, ) from .endpoint.exceptions import ( - EndpointUnavailableError, ServerInfoEndpointNotFoundError, + EndpointUnavailableError, ) from .exceptions import NotSignedInError from ..namespace import Namespace -import requests -from packaging.version import Version +from .._version import get_versions + +__TSC_VERSION__ = get_versions()["version"] +del get_versions _PRODUCT_TO_REST_VERSION = { "10.0": "2.3", @@ -47,6 +49,9 @@ "9.1": "2.0", "9.0": "2.0", } +minimum_supported_server_version = "2.3" +default_server_version = "2.3" +client_version_header = "X-TableauServerClient-Version" class Server(object): @@ -55,7 +60,7 @@ class PublishMode: Overwrite = "Overwrite" CreateNew = "CreateNew" - def __init__(self, server_address, use_server_version=True, http_options=None): + def __init__(self, server_address, use_server_version=False, http_options=None): self._server_address = server_address self._auth_token = None self._site_id = None @@ -63,7 +68,7 @@ def __init__(self, server_address, use_server_version=True, http_options=None): self._session = requests.Session() self._http_options = dict() - self.version = "2.3" + self.version = default_server_version self.auth = Auth(self) self.views = Views(self) self.users = Users(self) @@ -90,8 +95,10 @@ def __init__(self, server_address, use_server_version=True, http_options=None): self.flow_runs = FlowRuns(self) self.metrics = Metrics(self) + # must set this before calling use_server_version, because that's a server call if http_options: self.add_http_options(http_options) + self.add_http_version_header() if use_server_version: self.use_server_version() @@ -101,8 +108,13 @@ def add_http_options(self, options_dict): if options_dict.get("verify") == False: urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + def add_http_version_header(self): + if not self._http_options[client_version_header]: + self._http_options.update({client_version_header: __TSC_VERSION__}) + def clear_http_options(self): self._http_options = dict() + self.add_http_version_header() def _clear_auth(self): self._site_id = None @@ -144,13 +156,14 @@ def use_highest_version(self): warnings.warn("use use_server_version instead", DeprecationWarning) - def assert_at_least_version(self, version): + def check_at_least_version(self, target: str): server_version = Version(self.version or "0.0") - minimum_supported = Version(version) - if server_version < minimum_supported: - error = "This endpoint is not available in API version {}. Requires {}".format( - server_version, minimum_supported - ) + target_version = Version(target) + return server_version >= target_version + + def assert_at_least_version(self, comparison: str, reason: str): + if not self.check_at_least_version(comparison): + error = "{} is not available in API version {}. Requires {}".format(reason, self.version, comparison) raise EndpointUnavailableError(error) @property diff --git a/test/assets/Data/user_details.csv b/test/assets/Data/user_details.csv new file mode 100644 index 000000000..15b975942 --- /dev/null +++ b/test/assets/Data/user_details.csv @@ -0,0 +1 @@ +username, pword, , yes, email diff --git a/test/assets/Data/usernames.csv b/test/assets/Data/usernames.csv new file mode 100644 index 000000000..0350c0dd6 --- /dev/null +++ b/test/assets/Data/usernames.csv @@ -0,0 +1,7 @@ +valid, +valid@email.com, +domain/valid, +domain/valid@tmail.com, +va!@#$%^&*()lid, +in@v@lid, +in valid, diff --git a/test/assets/site_get_by_id.xml b/test/assets/site_get_by_id.xml index a47703fb6..a8a1e9a5c 100644 --- a/test/assets/site_get_by_id.xml +++ b/test/assets/site_get_by_id.xml @@ -1,4 +1,4 @@ - - \ No newline at end of file + + diff --git a/test/assets/site_get_by_name.xml b/test/assets/site_get_by_name.xml index 852f9594f..b7ae2b595 100644 --- a/test/assets/site_get_by_name.xml +++ b/test/assets/site_get_by_name.xml @@ -1,4 +1,4 @@ - - \ No newline at end of file + + diff --git a/test/assets/site_update.xml b/test/assets/site_update.xml index dbb166de1..1661a426b 100644 --- a/test/assets/site_update.xml +++ b/test/assets/site_update.xml @@ -1,4 +1,4 @@ - - \ No newline at end of file + + diff --git a/test/test_group.py b/test/test_group.py index d948090ca..306d42170 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -1,9 +1,7 @@ # encoding=utf-8 -import os import unittest - +import os import requests_mock - import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime @@ -129,8 +127,7 @@ def test_add_user_before_populating(self) -> None: with requests_mock.mock() as m: m.get(self.baseurl, text=get_xml_response) m.post( - "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/groups/ef8b19c0-43b6-11e6-af50" - "-63f5805dbe3c/users", + self.baseurl + "/ef8b19c0-43b6-11e6-af50-63f5805dbe3c/users", text=add_user_response, ) all_groups, pagination_item = self.server.groups.get() @@ -163,8 +160,7 @@ def test_remove_user_before_populating(self) -> None: with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) m.delete( - "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/groups/ef8b19c0-43b6-11e6-af50" - "-63f5805dbe3c/users/5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", + self.baseurl + "/ef8b19c0-43b6-11e6-af50-63f5805dbe3c/users/5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", text="ok", ) all_groups, pagination_item = self.server.groups.get() diff --git a/test/test_project.py b/test/test_project.py index 1d210eeb1..48e6005af 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -4,6 +4,7 @@ import requests_mock import tableauserverclient as TSC +from tableauserverclient import GroupItem from ._utils import read_xml_asset, asset TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -120,7 +121,7 @@ def test_update_datasource_default_permission(self) -> None: capabilities = {TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny} - rules = [TSC.PermissionsRule(grantee=group.to_reference(), capabilities=capabilities)] + rules = [TSC.PermissionsRule(grantee=GroupItem.as_reference(group._id), capabilities=capabilities)] new_rules = self.server.projects.update_datasource_default_permissions(project, rules) @@ -237,7 +238,7 @@ def test_delete_permission(self) -> None: if permission.grantee.id == single_group._id: capabilities = permission.capabilities - rules = TSC.PermissionsRule(grantee=single_group.to_reference(), capabilities=capabilities) + rules = TSC.PermissionsRule(grantee=GroupItem.as_reference(single_group._id), capabilities=capabilities) endpoint = "{}/permissions/groups/{}".format(single_project._id, single_group._id) m.delete("{}/{}/Read/Allow".format(self.baseurl, endpoint), status_code=204) @@ -283,7 +284,7 @@ def test_delete_workbook_default_permission(self) -> None: TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Allow, } - rules = TSC.PermissionsRule(grantee=single_group.to_reference(), capabilities=capabilities) + rules = TSC.PermissionsRule(grantee=GroupItem.as_reference(single_group._id), capabilities=capabilities) endpoint = "{}/default-permissions/workbooks/groups/{}".format(single_project._id, single_group._id) m.delete("{}/{}/Read/Allow".format(self.baseurl, endpoint), status_code=204) diff --git a/test/test_requests.py b/test/test_requests.py index 82859dd26..5c0d090ba 100644 --- a/test/test_requests.py +++ b/test/test_requests.py @@ -41,6 +41,7 @@ def test_make_post_request(self): ) self.assertEqual(resp.request.headers["x-tableau-auth"], "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM") self.assertEqual(resp.request.headers["content-type"], "multipart/mixed") + self.assertTrue(re.search("Tableau Server Client", resp.request.headers["user-agent"])) self.assertEqual(resp.request.body, b"1337") # Test that 500 server errors are handled properly diff --git a/test/test_site.py b/test/test_site.py index 23eb99ddd..b8469e56c 100644 --- a/test/test_site.py +++ b/test/test_site.py @@ -24,6 +24,9 @@ def setUp(self) -> None: self.server._site_id = "0626857c-1def-4503-a7d8-7907c3ff9d9f" self.baseurl = self.server.sites.baseurl + # sites APIs can only be called on the site being logged in to + self.logged_in_site = self.server.site_id + def test_get(self) -> None: with open(GET_XML, "rb") as f: response_xml = f.read().decode("utf-8") @@ -71,10 +74,10 @@ def test_get_by_id(self) -> None: with open(GET_BY_ID_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + "/dad65087-b08b-4603-af4e-2887b8aafc67", text=response_xml) - single_site = self.server.sites.get_by_id("dad65087-b08b-4603-af4e-2887b8aafc67") + m.get(self.baseurl + "/" + self.logged_in_site, text=response_xml) + single_site = self.server.sites.get_by_id(self.logged_in_site) - self.assertEqual("dad65087-b08b-4603-af4e-2887b8aafc67", single_site.id) + self.assertEqual(self.logged_in_site, single_site.id) self.assertEqual("Active", single_site.state) self.assertEqual("Default", single_site.name) self.assertEqual("ContentOnly", single_site.admin_mode) @@ -95,7 +98,7 @@ def test_get_by_name(self) -> None: m.get(self.baseurl + "/testsite?key=name", text=response_xml) single_site = self.server.sites.get_by_name("testsite") - self.assertEqual("dad65087-b08b-4603-af4e-2887b8aafc67", single_site.id) + self.assertEqual(self.logged_in_site, single_site.id) self.assertEqual("Active", single_site.state) self.assertEqual("testsite", single_site.name) self.assertEqual("ContentOnly", single_site.admin_mode) @@ -110,7 +113,7 @@ def test_update(self) -> None: with open(UPDATE_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.put(self.baseurl + "/6b7179ba-b82b-4f0f-91ed-812074ac5da6", text=response_xml) + m.put(self.baseurl + "/" + self.logged_in_site, text=response_xml) single_site = TSC.SiteItem( name="Tableau", content_url="tableau", @@ -143,10 +146,11 @@ def test_update(self) -> None: tier_explorer_capacity=5, tier_viewer_capacity=5, ) - single_site._id = "6b7179ba-b82b-4f0f-91ed-812074ac5da6" + single_site._id = self.logged_in_site + self.server.sites.parent_srv = self.server single_site = self.server.sites.update(single_site) - self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", single_site.id) + self.assertEqual(self.logged_in_site, single_site.id) self.assertEqual("tableau", single_site.content_url) self.assertEqual("Suspended", single_site.state) self.assertEqual("Tableau", single_site.name) diff --git a/test/test_user.py b/test/test_user.py index b8fe32388..1f5eba57f 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -1,5 +1,8 @@ +import io import os import unittest +from typing import List +from unittest.mock import MagicMock import requests_mock @@ -17,6 +20,9 @@ GET_FAVORITES_XML = os.path.join(TEST_ASSET_DIR, "favorites_get.xml") POPULATE_GROUPS_XML = os.path.join(TEST_ASSET_DIR, "user_populate_groups.xml") +USERNAMES = os.path.join(TEST_ASSET_DIR, "Data", "usernames.csv") +USERS = os.path.join(TEST_ASSET_DIR, "Data", "user_details.csv") + class UserTests(unittest.TestCase): def setUp(self) -> None: @@ -212,3 +218,21 @@ def test_populate_groups(self) -> None: self.assertEqual("86a66d40-f289-472a-83d0-927b0f954dc8", group_list[2].id) self.assertEqual("TableauExample", group_list[2].name) self.assertEqual("local", group_list[2].domain_name) + + def test_get_usernames_from_file(self): + with open(ADD_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.post(self.server.users.baseurl, text=response_xml) + user_list, failures = self.server.users.create_from_file(USERNAMES) + assert user_list[0].name == "Cassie", user_list + assert failures == [], failures + + def test_get_users_from_file(self): + with open(ADD_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.post(self.server.users.baseurl, text=response_xml) + users, failures = self.server.users.create_from_file(USERS) + assert users[0].name == "Cassie", users + assert failures == [] diff --git a/test/test_user_model.py b/test/test_user_model.py index ba70b1c7c..32d808f52 100644 --- a/test/test_user_model.py +++ b/test/test_user_model.py @@ -1,4 +1,10 @@ +import logging import unittest +from unittest.mock import * +from typing import List +import io + +import pytest import tableauserverclient as TSC @@ -23,3 +29,111 @@ def test_invalid_site_role(self): user = TSC.UserItem("me", TSC.UserItem.Roles.Publisher) with self.assertRaises(ValueError): user.site_role = "Hello" + + +class UserDataTest(unittest.TestCase): + + logger = logging.getLogger("UserDataTest") + + role_inputs = [ + ["creator", "system", "yes", "SiteAdministrator"], + ["None", "system", "no", "SiteAdministrator"], + ["explorer", "SysTEm", "no", "SiteAdministrator"], + ["creator", "site", "yes", "SiteAdministratorCreator"], + ["explorer", "site", "yes", "SiteAdministratorExplorer"], + ["creator", "SITE", "no", "SiteAdministratorCreator"], + ["creator", "none", "yes", "Creator"], + ["explorer", "none", "yes", "ExplorerCanPublish"], + ["viewer", "None", "no", "Viewer"], + ["explorer", "no", "yes", "ExplorerCanPublish"], + ["EXPLORER", "noNO", "yes", "ExplorerCanPublish"], + ["explorer", "no", "no", "Explorer"], + ["unlicensed", "none", "no", "Unlicensed"], + ["Chef", "none", "yes", "Unlicensed"], + ["yes", "yes", "yes", "Unlicensed"], + ] + + valid_import_content = [ + "username, pword, fname, creator, site, yes, email", + "username, pword, fname, explorer, none, no, email", + "", + "u", + "p", + ] + + valid_username_content = ["jfitzgerald@tableau.com"] + + usernames = [ + "valid", + "valid@email.com", + "domain/valid", + "domain/valid@tmail.com", + "va!@#$%^&*()lid", + "in@v@lid", + "in valid", + "", + ] + + def test_validate_usernames(self): + TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[0]) + TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[1]) + TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[2]) + TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[3]) + TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[4]) + with self.assertRaises(AttributeError): + TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[5]) + with self.assertRaises(AttributeError): + TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[6]) + + def test_evaluate_role(self): + for line in UserDataTest.role_inputs: + actual = TSC.UserItem.CSVImport._evaluate_site_role(line[0], line[1], line[2]) + assert actual == line[3], line + [actual] + + def test_get_user_detail_empty_line(self): + test_line = "" + test_user = TSC.UserItem.CSVImport.create_user_from_line(test_line) + assert test_user is None + + def test_get_user_detail_standard(self): + test_line = "username, pword, fname, license, admin, pub, email" + test_user: TSC.UserItem = TSC.UserItem.CSVImport.create_user_from_line(test_line) + assert test_user.name == "username", test_user.name + assert test_user.fullname == "fname", test_user.fullname + assert test_user.site_role == "Unlicensed", test_user.site_role + assert test_user.email == "email", test_user.email + + def test_get_user_details_only_username(self): + test_line = "username" + test_user: TSC.UserItem = TSC.UserItem.CSVImport.create_user_from_line(test_line) + + def test_populate_user_details_only_some(self): + values = "username, , , creator, admin" + user = TSC.UserItem.CSVImport.create_user_from_line(values) + assert user.name == "username" + + def test_validate_user_detail_standard(self): + test_line = "username, pword, fname, creator, site, 1, email" + TSC.UserItem.CSVImport._validate_import_line_or_throw(test_line, UserDataTest.logger) + TSC.UserItem.CSVImport.create_user_from_line(test_line) + + # for file handling + def _mock_file_content(self, content: List[str]) -> io.TextIOWrapper: + # the empty string represents EOF + # the tests run through the file twice, first to validate then to fetch + mock = MagicMock(io.TextIOWrapper) + content.append("") # EOF + mock.readline.side_effect = content + mock.name = "file-mock" + return mock + + def test_validate_import_file(self): + test_data = self._mock_file_content(UserDataTest.valid_import_content) + valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, UserDataTest.logger) + assert valid == 2, "Expected two lines to be parsed, got {}".format(valid) + assert invalid == [], "Expected no failures, got {}".format(invalid) + + def test_validate_usernames_file(self): + test_data = self._mock_file_content(UserDataTest.usernames) + valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, UserDataTest.logger) + assert valid == 5, "Exactly 5 of the lines were valid, counted {}".format(valid + invalid) diff --git a/versioneer.py b/versioneer.py index 59211ed6f..86c240e13 100755 --- a/versioneer.py +++ b/versioneer.py @@ -277,6 +277,7 @@ """ from __future__ import print_function + try: import configparser except ImportError: @@ -308,11 +309,13 @@ def get_root(): setup_py = os.path.join(root, "setup.py") versioneer_py = os.path.join(root, "versioneer.py") if not (os.path.exists(setup_py) or os.path.exists(versioneer_py)): - err = ("Versioneer was unable to run the project root directory. " - "Versioneer requires setup.py to be executed from " - "its immediate directory (like 'python setup.py COMMAND'), " - "or in a way that lets it use sys.argv[0] to find the root " - "(like 'python path/to/setup.py COMMAND').") + err = ( + "Versioneer was unable to run the project root directory. " + "Versioneer requires setup.py to be executed from " + "its immediate directory (like 'python setup.py COMMAND'), " + "or in a way that lets it use sys.argv[0] to find the root " + "(like 'python path/to/setup.py COMMAND')." + ) raise VersioneerBadRootError(err) try: # Certain runtime workflows (setup.py install/develop in a setuptools @@ -325,8 +328,7 @@ def get_root(): me_dir = os.path.normcase(os.path.splitext(me)[0]) vsr_dir = os.path.normcase(os.path.splitext(versioneer_py)[0]) if me_dir != vsr_dir: - print("Warning: build in %s is using versioneer.py from %s" - % (os.path.dirname(me), versioneer_py)) + print("Warning: build in %s is using versioneer.py from %s" % (os.path.dirname(me), versioneer_py)) except NameError: pass return root @@ -348,6 +350,7 @@ def get(parser, name): if parser.has_option("versioneer", name): return parser.get("versioneer", name) return None + cfg = VersioneerConfig() cfg.VCS = VCS cfg.style = get(parser, "style") or "" @@ -372,17 +375,18 @@ class NotThisMethod(Exception): def register_vcs_handler(vcs, method): # decorator """Decorator to mark a method as the handler for a particular VCS.""" + def decorate(f): """Store f in HANDLERS[vcs][method].""" if vcs not in HANDLERS: HANDLERS[vcs] = {} HANDLERS[vcs][method] = f return f + return decorate -def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, - env=None): +def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env=None): """Call the given command(s).""" assert isinstance(commands, list) p = None @@ -390,10 +394,9 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, try: dispcmd = str([c] + args) # remember shell=False, so use git.cmd on windows, not just git - p = subprocess.Popen([c] + args, cwd=cwd, env=env, - stdout=subprocess.PIPE, - stderr=(subprocess.PIPE if hide_stderr - else None)) + p = subprocess.Popen( + [c] + args, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=(subprocess.PIPE if hide_stderr else None) + ) break except EnvironmentError: e = sys.exc_info()[1] @@ -418,7 +421,9 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, return stdout, p.returncode -LONG_VERSION_PY['git'] = ''' +LONG_VERSION_PY[ + "git" +] = ''' # This file helps to compute a version number in source trees obtained from # git-archive tarball (such as those provided by githubs download-from-tag # feature). Distribution tarballs (built by setup.py sdist) and build @@ -993,7 +998,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. TAG = "tag: " - tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) + tags = set([r[len(TAG) :] for r in refs if r.startswith(TAG)]) if not tags: # Either we're using git < 1.8.3, or there really are no tags. We use # a heuristic: assume all version tags have a digit. The old git %d @@ -1002,7 +1007,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # between branches and tags. By ignoring refnames without digits, we # filter out many common branch names like "release" and # "stabilization", as well as "HEAD" and "master". - tags = set([r for r in refs if re.search(r'\d', r)]) + tags = set([r for r in refs if re.search(r"\d", r)]) if verbose: print("discarding '%s', no digits" % ",".join(refs - tags)) if verbose: @@ -1010,19 +1015,26 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): for ref in sorted(tags): # sorting will prefer e.g. "2.0" over "2.0rc1" if ref.startswith(tag_prefix): - r = ref[len(tag_prefix):] + r = ref[len(tag_prefix) :] if verbose: print("picking %s" % r) - return {"version": r, - "full-revisionid": keywords["full"].strip(), - "dirty": False, "error": None, - "date": date} + return { + "version": r, + "full-revisionid": keywords["full"].strip(), + "dirty": False, + "error": None, + "date": date, + } # no suitable tags, so version is "0+unknown", but full hex is still there if verbose: print("no suitable tags, using unknown + full revision id") - return {"version": "0+unknown", - "full-revisionid": keywords["full"].strip(), - "dirty": False, "error": "no suitable tags", "date": None} + return { + "version": "0+unknown", + "full-revisionid": keywords["full"].strip(), + "dirty": False, + "error": "no suitable tags", + "date": None, + } @register_vcs_handler("git", "pieces_from_vcs") @@ -1037,8 +1049,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if sys.platform == "win32": GITS = ["git.cmd", "git.exe"] - out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, - hide_stderr=True) + out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, hide_stderr=True) if rc != 0: if verbose: print("Directory %s not under git control" % root) @@ -1046,10 +1057,9 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] # if there isn't one, this yields HEX[-dirty] (no NUM) - describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty", - "--always", "--long", - "--match", "%s*" % tag_prefix], - cwd=root) + describe_out, rc = run_command( + GITS, ["describe", "--tags", "--dirty", "--always", "--long", "--match", "%s*" % tag_prefix], cwd=root + ) # --long was added in git-1.5.5 if describe_out is None: raise NotThisMethod("'git describe' failed") @@ -1072,17 +1082,16 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): dirty = git_describe.endswith("-dirty") pieces["dirty"] = dirty if dirty: - git_describe = git_describe[:git_describe.rindex("-dirty")] + git_describe = git_describe[: git_describe.rindex("-dirty")] # now we have TAG-NUM-gHEX or HEX if "-" in git_describe: # TAG-NUM-gHEX - mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) + mo = re.search(r"^(.+)-(\d+)-g([0-9a-f]+)$", git_describe) if not mo: # unparseable. Maybe git-describe is misbehaving? - pieces["error"] = ("unable to parse git-describe output: '%s'" - % describe_out) + pieces["error"] = "unable to parse git-describe output: '%s'" % describe_out return pieces # tag @@ -1091,10 +1100,9 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if verbose: fmt = "tag '%s' doesn't start with prefix '%s'" print(fmt % (full_tag, tag_prefix)) - pieces["error"] = ("tag '%s' doesn't start with prefix '%s'" - % (full_tag, tag_prefix)) + pieces["error"] = "tag '%s' doesn't start with prefix '%s'" % (full_tag, tag_prefix) return pieces - pieces["closest-tag"] = full_tag[len(tag_prefix):] + pieces["closest-tag"] = full_tag[len(tag_prefix) :] # distance: number of commits since tag pieces["distance"] = int(mo.group(2)) @@ -1105,13 +1113,11 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): else: # HEX: no tags pieces["closest-tag"] = None - count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], - cwd=root) + count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], cwd=root) pieces["distance"] = int(count_out) # total number of commits # commit date: see ISO-8601 comment in git_versions_from_keywords() - date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"], - cwd=root)[0].strip() + date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip() pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) return pieces @@ -1167,16 +1173,19 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): for i in range(3): dirname = os.path.basename(root) if dirname.startswith(parentdir_prefix): - return {"version": dirname[len(parentdir_prefix):], - "full-revisionid": None, - "dirty": False, "error": None, "date": None} + return { + "version": dirname[len(parentdir_prefix) :], + "full-revisionid": None, + "dirty": False, + "error": None, + "date": None, + } else: rootdirs.append(root) root = os.path.dirname(root) # up a level if verbose: - print("Tried directories %s but none started with prefix %s" % - (str(rootdirs), parentdir_prefix)) + print("Tried directories %s but none started with prefix %s" % (str(rootdirs), parentdir_prefix)) raise NotThisMethod("rootdir doesn't start with parentdir_prefix") @@ -1205,11 +1214,9 @@ def versions_from_file(filename): contents = f.read() except EnvironmentError: raise NotThisMethod("unable to read _version.py") - mo = re.search(r"version_json = '''\n(.*)''' # END VERSION_JSON", - contents, re.M | re.S) + mo = re.search(r"version_json = '''\n(.*)''' # END VERSION_JSON", contents, re.M | re.S) if not mo: - mo = re.search(r"version_json = '''\r\n(.*)''' # END VERSION_JSON", - contents, re.M | re.S) + mo = re.search(r"version_json = '''\r\n(.*)''' # END VERSION_JSON", contents, re.M | re.S) if not mo: raise NotThisMethod("no version_json in _version.py") return json.loads(mo.group(1)) @@ -1218,8 +1225,7 @@ def versions_from_file(filename): def write_to_version_file(filename, versions): """Write the given version number to the given _version.py file.""" os.unlink(filename) - contents = json.dumps(versions, sort_keys=True, - indent=1, separators=(",", ": ")) + contents = json.dumps(versions, sort_keys=True, indent=1, separators=(",", ": ")) with open(filename, "w") as f: f.write(SHORT_VERSION_PY % contents) @@ -1251,8 +1257,7 @@ def render_pep440(pieces): rendered += ".dirty" else: # exception #1 - rendered = "0+untagged.%d.g%s" % (pieces["distance"], - pieces["short"]) + rendered = "0+untagged.%d.g%s" % (pieces["distance"], pieces["short"]) if pieces["dirty"]: rendered += ".dirty" return rendered @@ -1366,11 +1371,13 @@ def render_git_describe_long(pieces): def render(pieces, style): """Render the given version pieces into the requested style.""" if pieces["error"]: - return {"version": "unknown", - "full-revisionid": pieces.get("long"), - "dirty": None, - "error": pieces["error"], - "date": None} + return { + "version": "unknown", + "full-revisionid": pieces.get("long"), + "dirty": None, + "error": pieces["error"], + "date": None, + } if not style or style == "default": style = "pep440" # the default @@ -1390,9 +1397,13 @@ def render(pieces, style): else: raise ValueError("unknown style '%s'" % style) - return {"version": rendered, "full-revisionid": pieces["long"], - "dirty": pieces["dirty"], "error": None, - "date": pieces.get("date")} + return { + "version": rendered, + "full-revisionid": pieces["long"], + "dirty": pieces["dirty"], + "error": None, + "date": pieces.get("date"), + } class VersioneerBadRootError(Exception): @@ -1415,8 +1426,7 @@ def get_versions(verbose=False): handlers = HANDLERS.get(cfg.VCS) assert handlers, "unrecognized VCS '%s'" % cfg.VCS verbose = verbose or cfg.verbose - assert cfg.versionfile_source is not None, \ - "please set versioneer.versionfile_source" + assert cfg.versionfile_source is not None, "please set versioneer.versionfile_source" assert cfg.tag_prefix is not None, "please set versioneer.tag_prefix" versionfile_abs = os.path.join(root, cfg.versionfile_source) @@ -1470,9 +1480,13 @@ def get_versions(verbose=False): if verbose: print("unable to compute version") - return {"version": "0+unknown", "full-revisionid": None, - "dirty": None, "error": "unable to compute version", - "date": None} + return { + "version": "0+unknown", + "full-revisionid": None, + "dirty": None, + "error": "unable to compute version", + "date": None, + } def get_version(): @@ -1521,6 +1535,7 @@ def run(self): print(" date: %s" % vers.get("date")) if vers["error"]: print(" error: %s" % vers["error"]) + cmds["version"] = cmd_version # we override "build_py" in both distutils and setuptools @@ -1553,14 +1568,15 @@ def run(self): # now locate _version.py in the new build/ directory and replace # it with an updated value if cfg.versionfile_build: - target_versionfile = os.path.join(self.build_lib, - cfg.versionfile_build) + target_versionfile = os.path.join(self.build_lib, cfg.versionfile_build) print("UPDATING %s" % target_versionfile) write_to_version_file(target_versionfile, versions) + cmds["build_py"] = cmd_build_py if "cx_Freeze" in sys.modules: # cx_freeze enabled? from cx_Freeze.dist import build_exe as _build_exe + # nczeczulin reports that py2exe won't like the pep440-style string # as FILEVERSION, but it can be used for PRODUCTVERSION, e.g. # setup(console=[{ @@ -1581,17 +1597,21 @@ def run(self): os.unlink(target_versionfile) with open(cfg.versionfile_source, "w") as f: LONG = LONG_VERSION_PY[cfg.VCS] - f.write(LONG % - {"DOLLAR": "$", - "STYLE": cfg.style, - "TAG_PREFIX": cfg.tag_prefix, - "PARENTDIR_PREFIX": cfg.parentdir_prefix, - "VERSIONFILE_SOURCE": cfg.versionfile_source, - }) + f.write( + LONG + % { + "DOLLAR": "$", + "STYLE": cfg.style, + "TAG_PREFIX": cfg.tag_prefix, + "PARENTDIR_PREFIX": cfg.parentdir_prefix, + "VERSIONFILE_SOURCE": cfg.versionfile_source, + } + ) + cmds["build_exe"] = cmd_build_exe del cmds["build_py"] - if 'py2exe' in sys.modules: # py2exe enabled? + if "py2exe" in sys.modules: # py2exe enabled? try: from py2exe.distutils_buildexe import py2exe as _py2exe # py3 except ImportError: @@ -1610,13 +1630,17 @@ def run(self): os.unlink(target_versionfile) with open(cfg.versionfile_source, "w") as f: LONG = LONG_VERSION_PY[cfg.VCS] - f.write(LONG % - {"DOLLAR": "$", - "STYLE": cfg.style, - "TAG_PREFIX": cfg.tag_prefix, - "PARENTDIR_PREFIX": cfg.parentdir_prefix, - "VERSIONFILE_SOURCE": cfg.versionfile_source, - }) + f.write( + LONG + % { + "DOLLAR": "$", + "STYLE": cfg.style, + "TAG_PREFIX": cfg.tag_prefix, + "PARENTDIR_PREFIX": cfg.parentdir_prefix, + "VERSIONFILE_SOURCE": cfg.versionfile_source, + } + ) + cmds["py2exe"] = cmd_py2exe # we override different "sdist" commands for both environments @@ -1643,8 +1667,8 @@ def make_release_tree(self, base_dir, files): # updated value target_versionfile = os.path.join(base_dir, cfg.versionfile_source) print("UPDATING %s" % target_versionfile) - write_to_version_file(target_versionfile, - self._versioneer_generated_versions) + write_to_version_file(target_versionfile, self._versioneer_generated_versions) + cmds["sdist"] = cmd_sdist return cmds @@ -1699,11 +1723,9 @@ def do_setup(): root = get_root() try: cfg = get_config_from_root(root) - except (EnvironmentError, configparser.NoSectionError, - configparser.NoOptionError) as e: + except (EnvironmentError, configparser.NoSectionError, configparser.NoOptionError) as e: if isinstance(e, (EnvironmentError, configparser.NoSectionError)): - print("Adding sample versioneer config to setup.cfg", - file=sys.stderr) + print("Adding sample versioneer config to setup.cfg", file=sys.stderr) with open(os.path.join(root, "setup.cfg"), "a") as f: f.write(SAMPLE_CONFIG) print(CONFIG_ERROR, file=sys.stderr) @@ -1712,15 +1734,18 @@ def do_setup(): print(" creating %s" % cfg.versionfile_source) with open(cfg.versionfile_source, "w") as f: LONG = LONG_VERSION_PY[cfg.VCS] - f.write(LONG % {"DOLLAR": "$", - "STYLE": cfg.style, - "TAG_PREFIX": cfg.tag_prefix, - "PARENTDIR_PREFIX": cfg.parentdir_prefix, - "VERSIONFILE_SOURCE": cfg.versionfile_source, - }) - - ipy = os.path.join(os.path.dirname(cfg.versionfile_source), - "__init__.py") + f.write( + LONG + % { + "DOLLAR": "$", + "STYLE": cfg.style, + "TAG_PREFIX": cfg.tag_prefix, + "PARENTDIR_PREFIX": cfg.parentdir_prefix, + "VERSIONFILE_SOURCE": cfg.versionfile_source, + } + ) + + ipy = os.path.join(os.path.dirname(cfg.versionfile_source), "__init__.py") if os.path.exists(ipy): try: with open(ipy, "r") as f: @@ -1762,8 +1787,7 @@ def do_setup(): else: print(" 'versioneer.py' already in MANIFEST.in") if cfg.versionfile_source not in simple_includes: - print(" appending versionfile_source ('%s') to MANIFEST.in" % - cfg.versionfile_source) + print(" appending versionfile_source ('%s') to MANIFEST.in" % cfg.versionfile_source) with open(manifest_in, "a") as f: f.write("include %s\n" % cfg.versionfile_source) else: From 5cb22ca7b0a00e7e5d24fdcbd470495201cf4299 Mon Sep 17 00:00:00 2001 From: fossabot Date: Mon, 12 Sep 2022 19:37:19 -0500 Subject: [PATCH 013/296] Add license scan report and status (#1106) Signed off by: fossabot Co-authored-by: Jac --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index b21c2665d..71bf9b023 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Tableau Server Client (Python) [![Tableau Supported](https://img.shields.io/badge/Support%20Level-Tableau%20Supported-53bd92.svg)](https://www.tableau.com/support-levels-it-and-developer-tools) [![Build Status](https://github.com/tableau/server-client-python/actions/workflows/run-tests.yml/badge.svg)](https://github.com/tableau/server-client-python/actions) +[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python?ref=badge_shield) Use the Tableau Server Client (TSC) library to increase your productivity as you interact with the Tableau Server REST API. With the TSC library you can do almost everything that you can do with the REST API, including: @@ -15,3 +16,7 @@ To see sample code that works directly with the REST API (in Java, Python, or Po For more information on installing and using TSC, see the documentation: + + +## License +[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python?ref=badge_large) \ No newline at end of file From ef169e7786659a22c8a1467b5f5a87c9123f3a3f Mon Sep 17 00:00:00 2001 From: Jac Date: Mon, 12 Sep 2022 18:14:15 -0700 Subject: [PATCH 014/296] Update publish-pypi.yml (#1109) --- .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 9b4e842ee..eedb48138 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -24,7 +24,7 @@ jobs: run: | python -m pip install --upgrade pip pip install -e .[test] - python setup.py sdist --formats=gztar bdist_wheel + python -m build git describe --tag --dirty --always - name: Publish distribution 📦 to Test PyPI uses: pypa/gh-action-pypi-publish@release/v1 # license BSD-2 From 8684058f94431678d3df4cac5d57112e0c168914 Mon Sep 17 00:00:00 2001 From: Adrian Vogelsgesang Date: Wed, 14 Sep 2022 23:14:37 +0200 Subject: [PATCH 015/296] Fix `filepath` parameter in `publish_{datasource,workbook}` samples (#1112) --- samples/publish_datasource.py | 4 ++-- samples/publish_workbook.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/samples/publish_datasource.py b/samples/publish_datasource.py index eecbe7088..8d9e59ea2 100644 --- a/samples/publish_datasource.py +++ b/samples/publish_datasource.py @@ -96,13 +96,13 @@ def main(): if args.async_: # Async publishing, returns a job_item new_job = server.datasources.publish( - new_datasource, args.filepath, publish_mode, connection_credentials=new_conn_creds, as_job=True + new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds, as_job=True ) print("Datasource published asynchronously. Job ID: {0}".format(new_job.id)) else: # Normal publishing, returns a datasource_item new_datasource = server.datasources.publish( - new_datasource, args.filepath, publish_mode, connection_credentials=new_conn_creds + new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds ) print("Datasource published. Datasource ID: {0}".format(new_datasource.id)) diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 3cc27c582..a0bf1794b 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -80,7 +80,7 @@ def main(): if args.as_job: new_job = server.workbooks.publish( new_workbook, - args.filepath, + args.file, overwrite_true, connections=all_connections, as_job=args.as_job, @@ -90,7 +90,7 @@ def main(): else: new_workbook = server.workbooks.publish( new_workbook, - args.filepath, + args.file, overwrite_true, connections=all_connections, as_job=args.as_job, From d1b7a6c89d1125a7725e51789d95869eb4fa287d Mon Sep 17 00:00:00 2001 From: jorwoods Date: Mon, 19 Sep 2022 14:01:25 -0500 Subject: [PATCH 016/296] Add build package to pip install (#1113) * Add build package to pip install * Add build check to tests --- .github/workflows/publish-pypi.yml | 2 +- .github/workflows/run-tests.yml | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index eedb48138..33438bed8 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -23,7 +23,7 @@ jobs: - name: Build dist files run: | python -m pip install --upgrade pip - pip install -e .[test] + pip install -e .[test] build python -m build git describe --tag --dirty --always - name: Publish distribution 📦 to Test PyPI diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index b83af5a4b..10df02c04 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -23,9 +23,14 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -e .[test] + pip install -e .[test] build - name: Test with pytest if: always() run: | pytest test + + - name: Test build + if: always() + run: | + python -m build \ No newline at end of file From f653e15b582ae2ea7fc76f423f178d430e2a30ed Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 20 Sep 2022 02:13:05 -0700 Subject: [PATCH 017/296] Development (#1114) git cleanup merge: make the development branch really believe it is up to date with master --- setup.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/setup.py b/setup.py index 60d8fe6b8..dfd43ae8a 100644 --- a/setup.py +++ b/setup.py @@ -14,9 +14,11 @@ version=versioneer.get_version(), cmdclass=versioneer.get_cmdclass(), # not yet sure how to move this to pyproject.toml - packages=['tableauserverclient', - 'tableauserverclient.helpers', - 'tableauserverclient.models', - 'tableauserverclient.server', - 'tableauserverclient.server.endpoint'], + packages=[ + "tableauserverclient", + "tableauserverclient.helpers", + "tableauserverclient.models", + "tableauserverclient.server", + "tableauserverclient.server.endpoint", + ], ) From ef9e7fd23b70bf64beb3e5db41fc5d1dd9e5c1f5 Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 28 Sep 2022 00:41:42 -0700 Subject: [PATCH 018/296] Add custom session injection, Fix bug for http_options (#1119) * ssl-verify is an option, not a header * Allow injection of session_factory to allow use of a custom session * show server info (#1118) * Fix bug in exposing ExcelRequestOptions and test (#1123) * Fix a few pylint errors (#1124) Co-authored-by: Marwan Baghdad Co-authored-by: jorwoods Co-authored-by: Brian Cantoni --- contributing.md | 10 ++- samples/create_group.py | 2 +- samples/initialize_server.py | 6 +- tableauserverclient/__init__.py | 1 + tableauserverclient/models/flow_item.py | 4 - .../models/permissions_item.py | 2 +- tableauserverclient/models/revision_item.py | 5 +- .../models/server_info_item.py | 9 ++- tableauserverclient/models/site_item.py | 1 - tableauserverclient/models/tableau_auth.py | 2 +- tableauserverclient/server/__init__.py | 1 + .../server/endpoint/databases_endpoint.py | 2 +- .../server/endpoint/endpoint.py | 34 +++----- .../server/endpoint/server_info_endpoint.py | 21 ++++- tableauserverclient/server/server.py | 62 +++++++++------ test/http/test_http_requests.py | 79 +++++++++++++++++++ test/test_view.py | 2 +- 17 files changed, 174 insertions(+), 69 deletions(-) create mode 100644 test/http/test_http_requests.py diff --git a/contributing.md b/contributing.md index 90fbdc4f0..41c339cb6 100644 --- a/contributing.md +++ b/contributing.md @@ -66,18 +66,22 @@ pytest pip install . ``` +### Debugging Tools +See what your outgoing requests look like: https://requestbin.net/ (unaffiliated link not under our control) + + ### Before Committing Our CI runs include a Python lint run, so you should run this locally and fix complaints before committing as this will fail your checkin. ```shell # this will run the formatter without making changes -black --line-length 120 tableauserverclient test samples --check +black . --check # this will format the directory and code for you -black --line-length 120 tableauserverclient test samples +black . # this will run type checking pip install mypy -mypy --show-error-codes --disable-error-code misc --disable-error-code import tableauserverclient test +mypy tableauserverclient test samples ``` diff --git a/samples/create_group.py b/samples/create_group.py index 50d84a187..d5cf712db 100644 --- a/samples/create_group.py +++ b/samples/create_group.py @@ -46,7 +46,7 @@ def main(): 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=True) + server = TSC.Server(args.server, use_server_version=True, http_options={"verify": False}) with server.auth.sign_in(tableau_auth): # this code shows 3 different error codes that mean "resource is already in collection" # 409009: group already exists on server diff --git a/samples/initialize_server.py b/samples/initialize_server.py index 586011120..21b243013 100644 --- a/samples/initialize_server.py +++ b/samples/initialize_server.py @@ -56,7 +56,7 @@ def main(): # Create the site if it doesn't exist if existing_site is None: - print("Site not found: {0} Creating it...").format(args.site_id) + print("Site not found: {0} Creating it...".format(args.site_id)) new_site = TSC.SiteItem( name=args.site_id, content_url=args.site_id.replace(" ", ""), @@ -64,7 +64,7 @@ def main(): ) server.sites.create(new_site) else: - print("Site {0} exists. Moving on...").format(args.site_id) + print("Site {0} exists. Moving on...".format(args.site_id)) ################################################################################ # Step 3: Sign-in to our target site @@ -87,7 +87,7 @@ def main(): # Create our project if it doesn't exist if project is None: - print("Project not found: {0} Creating it...").format(args.project) + print("Project not found: {0} Creating it...".format(args.project)) new_project = TSC.ProjectItem(name=args.project) project = server_upload.projects.create(new_project) diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 394184120..7c1e6d705 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -39,6 +39,7 @@ ) from .server import ( CSVRequestOptions, + ExcelRequestOptions, ImageRequestOptions, PDFRequestOptions, RequestOptions, diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index d957f5e14..18f0ecae2 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -93,10 +93,6 @@ def description(self, value: str) -> None: def project_name(self) -> Optional[str]: return self._project_name - @property - def flow_type(self): # What is this? It doesn't seem to get set anywhere. - return self._flow_type - @property def updated_at(self) -> Optional["datetime.datetime"]: return self._updated_at diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 1c1e9db4d..74b167e9d 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -69,7 +69,7 @@ def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: mode = capability_xml.get("mode") if name is None or mode is None: - logger.error("Capability was not valid: ", capability_xml) + logger.error("Capability was not valid: {}".format(capability_xml)) raise UnpopulatedPropertyError() else: capability_dict[name] = mode diff --git a/tableauserverclient/models/revision_item.py b/tableauserverclient/models/revision_item.py index 024d45edd..a49be88a7 100644 --- a/tableauserverclient/models/revision_item.py +++ b/tableauserverclient/models/revision_item.py @@ -53,8 +53,9 @@ def user_name(self) -> Optional[str]: def __repr__(self): return ( - "" - ).format(**self.__dict__) + "".format(**self.__dict__) + ) @classmethod def from_response(cls, resp: bytes, ns, resource_item) -> List["RevisionItem"]: diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py index d0ac5d292..350ae3a0d 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -1,3 +1,6 @@ +import warnings +import xml + from defusedxml.ElementTree import fromstring @@ -32,7 +35,11 @@ def rest_api_version(self): @classmethod def from_response(cls, resp, ns): - parsed_response = fromstring(resp) + try: + parsed_response = fromstring(resp) + except xml.etree.ElementTree.ParseError as error: + warnings.warn("Unexpected response for ServerInfo: {}".format(resp)) + return cls("Unknown", "Unknown", "Unknown") product_version_tag = parsed_response.find(".//t:productVersion", namespaces=ns) rest_api_version_tag = parsed_response.find(".//t:restApiVersion", namespaces=ns) diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index 3deda03e2..8c9e8fe8e 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -1,7 +1,6 @@ import warnings import xml.etree.ElementTree as ET -from distutils.version import Version from defusedxml.ElementTree import fromstring from .property_decorators import ( property_is_enum, diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index f373a84ab..6ad0fda5a 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -9,7 +9,7 @@ def credentials(self): +"This method returns values to set as an attribute on the credentials element of the request" def __repr__(self): - display = "All Credentials types must have a debug display that does not print secrets" + return "All Credentials types must have a debug display that does not print secrets" def deprecate_site_attribute(): diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index 25abb3c9a..84d118a2e 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -2,6 +2,7 @@ from .request_factory import RequestFactory from .request_options import ( CSVRequestOptions, + ExcelRequestOptions, ImageRequestOptions, PDFRequestOptions, RequestOptions, diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index 1fab7ac4b..aa9d73f18 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -116,7 +116,7 @@ def update_table_default_permissions(self, item): @api(version="3.5") def delete_table_default_permissions(self, item): - self._default_permissions.delete_default_permissions(item, Resource.Table) + self._default_permissions.delete_default_permission(item, Resource.Table) @api(version="3.5") def populate_dqw(self, item): diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 378c84746..3cdc49322 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,6 +1,6 @@ import requests import logging -from distutils.version import LooseVersion as Version +from packaging.version import Version from functools import wraps from xml.etree.ElementTree import ParseError from typing import Any, Callable, Dict, Optional, TYPE_CHECKING @@ -11,9 +11,12 @@ NonXMLResponseError, EndpointUnavailableError, ) -from .. import endpoint from ..query import QuerySet from ... import helpers +from ..._version import get_versions + +__TSC_VERSION__ = get_versions()["version"] +del get_versions logger = logging.getLogger("tableau.endpoint") @@ -22,34 +25,25 @@ XML_CONTENT_TYPE = "text/xml" JSON_CONTENT_TYPE = "application/json" +USERAGENT_HEADER = "User-Agent" + if TYPE_CHECKING: from ..server import Server from requests import Response -_version_header: Optional[str] = None - - class Endpoint(object): def __init__(self, parent_srv: "Server"): - global _version_header self.parent_srv = parent_srv @staticmethod def _make_common_headers(auth_token, content_type): - global _version_header - - if not _version_header: - from ..server import __TSC_VERSION__ - - _version_header = __TSC_VERSION__ - headers = {} if auth_token is not None: headers["x-tableau-auth"] = auth_token if content_type is not None: headers["content-type"] = content_type - headers["User-Agent"] = "Tableau Server Client/{}".format(_version_header) + headers["User-Agent"] = "Tableau Server Client/{}".format(__TSC_VERSION__) return headers def _make_request( @@ -62,9 +56,9 @@ def _make_request( parameters: Optional[Dict[str, Any]] = None, ) -> "Response": parameters = parameters or {} - parameters.update(self.parent_srv.http_options) if "headers" not in parameters: parameters["headers"] = {} + parameters.update(self.parent_srv.http_options) parameters["headers"].update(Endpoint._make_common_headers(auth_token, content_type)) if content is not None: @@ -89,14 +83,12 @@ def _check_status(self, server_response, url: str = None): if server_response.status_code >= 500: raise InternalServerError(server_response, url) elif server_response.status_code not in Success_codes: - # todo: is an error reliably of content-type application/xml? try: raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace, url) except ParseError: - # This will happen if we get a non-success HTTP code that - # doesn't return an xml error object (like metadata endpoints or 503 pages) - # we convert this to a better exception and pass through the raw - # response body + # This will happen if we get a non-success HTTP code that doesn't return an xml error object + # e.g metadata endpoints, 503 pages, totally different servers + # we convert this to a better exception and pass through the raw response body raise NonXMLResponseError(server_response.content) except Exception: # anything else re-raise here @@ -194,7 +186,7 @@ def api(version): def _decorator(func): @wraps(func) def wrapper(self, *args, **kwargs): - self.parent_srv.assert_at_least_version(version, "endpoint") + self.parent_srv.assert_at_least_version(version, self.__class__.__name__) return func(self, *args, **kwargs) return wrapper diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index 2036d8d5e..943aabee6 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -12,6 +12,19 @@ class ServerInfo(Endpoint): + def __init__(self, server): + self.parent_srv = server + self._info = None + + @property + def serverInfo(self): + if not self._info: + self.get() + return self._info + + def __repr__(self): + return "".format(self.serverInfo) + @property def baseurl(self): return "{0}/serverInfo".format(self.parent_srv.baseurl) @@ -23,10 +36,10 @@ def get(self): server_response = self.get_unauthenticated_request(self.baseurl) except ServerResponseError as e: if e.code == "404003": - raise ServerInfoEndpointNotFoundError + raise ServerInfoEndpointNotFoundError(e) if e.code == "404001": - raise EndpointUnavailableError + raise EndpointUnavailableError(e) raise e - server_info = ServerInfoItem.from_response(server_response.content, self.parent_srv.namespace) - return server_info + self._info = ServerInfoItem.from_response(server_response.content, self.parent_srv.namespace) + return self._info diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index c82f4a6e2..ebe11dac7 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -1,3 +1,5 @@ +import warnings + import requests import urllib3 @@ -37,11 +39,6 @@ from ..namespace import Namespace -from .._version import get_versions - -__TSC_VERSION__ = get_versions()["version"] -del get_versions - _PRODUCT_TO_REST_VERSION = { "10.0": "2.3", "9.3": "2.2", @@ -51,7 +48,6 @@ } minimum_supported_server_version = "2.3" default_server_version = "2.3" -client_version_header = "X-TableauServerClient-Version" class Server(object): @@ -60,15 +56,14 @@ class PublishMode: Overwrite = "Overwrite" CreateNew = "CreateNew" - def __init__(self, server_address, use_server_version=False, http_options=None): - self._server_address = server_address + def __init__(self, server_address, use_server_version=False, http_options=None, session_factory=None): self._auth_token = None self._site_id = None self._user_id = None - self._session = requests.Session() - self._http_options = dict() - self.version = default_server_version + self._server_address = server_address + self._session_factory = session_factory or requests.session + self.auth = Auth(self) self.views = Views(self) self.users = Users(self) @@ -95,32 +90,48 @@ def __init__(self, server_address, use_server_version=False, http_options=None): self.flow_runs = FlowRuns(self) self.metrics = Metrics(self) - # must set this before calling use_server_version, because that's a server call + self._session = self._session_factory() + self._http_options = dict() # must set this before making a server call if http_options: self.add_http_options(http_options) - self.add_http_version_header() + self.validate_server_connection() + + self.version = default_server_version if use_server_version: - self.use_server_version() + self.use_server_version() # this makes a server call - def add_http_options(self, options_dict): - self._http_options.update(options_dict) - if options_dict.get("verify") == False: - urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + def validate_server_connection(self): + try: + self._session.prepare_request(requests.Request("GET", url=self._server_address, params=self._http_options)) + except Exception as req_ex: + warnings.warn("Invalid server initialization\n {}".format(req_ex.__str__()), UserWarning) + print("==================") - def add_http_version_header(self): - if not self._http_options[client_version_header]: - self._http_options.update({client_version_header: __TSC_VERSION__}) + def __repr__(self): + return " [Connection: {}, {}]".format(self.baseurl, self.server_info.serverInfo) + + def add_http_options(self, options_dict: dict): + try: + self._http_options.update(options_dict) + if "verify" in options_dict.keys() and self._http_options.get("verify") is False: + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + # would be nice if you could turn them back on + except BaseException as be: + print(be) + # expected errors on invalid input: + # 'set' object has no attribute 'keys', 'list' object has no attribute 'keys' + # TypeError: cannot convert dictionary update sequence element #0 to a sequence (input is a tuple) + raise ValueError("Invalid http options given: {}".format(options_dict)) def clear_http_options(self): self._http_options = dict() - self.add_http_version_header() def _clear_auth(self): self._site_id = None self._user_id = None self._auth_token = None - self._session = requests.Session() + self._session = self._session_factory() def _set_auth(self, site_id, user_id, auth_token): self._site_id = site_id @@ -141,9 +152,10 @@ def _determine_highest_version(self): version = self.server_info.get().rest_api_version except ServerInfoEndpointNotFoundError: version = self._get_legacy_version() + except BaseException: + version = self._get_legacy_version() - finally: - self.version = old_version + self.version = old_version return version diff --git a/test/http/test_http_requests.py b/test/http/test_http_requests.py new file mode 100644 index 000000000..a5f4f4669 --- /dev/null +++ b/test/http/test_http_requests.py @@ -0,0 +1,79 @@ +import tableauserverclient as TSC +import unittest +import requests + +from requests_mock import adapter, mock +from requests.exceptions import MissingSchema + + +class ServerTests(unittest.TestCase): + def test_init_server_model_empty_throws(self): + with self.assertRaises(TypeError): + server = TSC.Server() + + def test_init_server_model_bad_server_name_complains(self): + # by default, it will just set the version to 2.3 + server = TSC.Server("fake-url") + + def test_init_server_model_valid_server_name_works(self): + # by default, it will just set the version to 2.3 + server = TSC.Server("http://fake-url") + + def test_init_server_model_valid_https_server_name_works(self): + # by default, it will just set the version to 2.3 + server = TSC.Server("https://fake-url") + + def test_init_server_model_bad_server_name_not_version_check(self): + # by default, it will just set the version to 2.3 + server = TSC.Server("fake-url", use_server_version=False) + + def test_init_server_model_bad_server_name_do_version_check(self): + with self.assertRaises(MissingSchema): + server = TSC.Server("fake-url", use_server_version=True) + + def test_init_server_model_bad_server_name_not_version_check_random_options(self): + # by default, it will just set the version to 2.3 + server = TSC.Server("fake-url", use_server_version=False, http_options={"foo": 1}) + + def test_init_server_model_bad_server_name_not_version_check_real_options(self): + server = TSC.Server("fake-url", use_server_version=False, http_options={"verify": False}) + + def test_http_options_skip_ssl_works(self): + http_options = {"verify": False} + server = TSC.Server("http://fake-url") + server.add_http_options(http_options) + + def test_http_options_multiple_options_works(self): + http_options = {"verify": False, "birdname": "Parrot"} + server = TSC.Server("http://fake-url") + server.add_http_options(http_options) + + # ValueError: dictionary update sequence element #0 has length 1; 2 is required + def test_http_options_multiple_dicts_fails(self): + http_options_1 = {"verify": False} + http_options_2 = {"birdname": "Parrot"} + server = TSC.Server("http://fake-url") + with self.assertRaises(ValueError): + server.add_http_options([http_options_1, http_options_2]) + + # TypeError: cannot convert dictionary update sequence element #0 to a sequence + def test_http_options_not_sequence_fails(self): + server = TSC.Server("http://fake-url") + with self.assertRaises(ValueError): + server.add_http_options({1, 2, 3}) + + +class SessionTests(unittest.TestCase): + test_header = {"x-test": "true"} + + @staticmethod + def session_factory(): + session = requests.session() + session.headers.update(SessionTests.test_header) + return session + + def test_session_factory_adds_headers(self): + test_request_bin = "http://capture-this-with-mock.com" + with mock() as m: + m.get(url="http://capture-this-with-mock.com/api/2.4/serverInfo", request_headers=SessionTests.test_header) + server = TSC.Server(test_request_bin, use_server_version=True, session_factory=SessionTests.session_factory) diff --git a/test/test_view.py b/test/test_view.py index 3562650d1..f5d3db47b 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -294,7 +294,7 @@ def test_populate_excel(self) -> None: m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/crosstab/excel?maxAge=1", content=response) single_view = TSC.ViewItem() single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" - request_option = TSC.CSVRequestOptions(maxage=1) + request_option = TSC.ExcelRequestOptions(maxage=1) self.server.views.populate_excel(single_view, request_option) excel_file = b"".join(single_view.excel) From 4873a5821177f22d319ab7ba28f6d9e18ec7aeaa Mon Sep 17 00:00:00 2001 From: Jac Date: Mon, 10 Oct 2022 19:42:45 -0700 Subject: [PATCH 019/296] Patch fix: 0.23.1 (#1129) * Allow injection of session_factory to allow use of a custom session * Jac/show server info (#1118) * Fix bug in exposing ExcelRequestOptions and test (#1123) * Fix a few pylint errors (#1124) * fix behavior when url has no protocol (#1125) * smoke test for pypi * Add permission control for Data Roles and Metrics (Issue #1063) (#1120) Co-authored-by: Marwan Baghdad Co-authored-by: jorwoods Co-authored-by: Brian Cantoni Co-authored-by: TrimPeachu <77048868+TrimPeachu@users.noreply.github.com> --- .github/workflows/pypi-smoke-tests.yml | 36 +++++++++++++ LICENSE | 2 +- samples/smoke_test.py | 8 +++ tableauserverclient/__init__.py | 1 + tableauserverclient/models/tableau_auth.py | 2 +- tableauserverclient/models/tableau_types.py | 2 + .../server/endpoint/auth_endpoint.py | 13 ++++- .../server/endpoint/endpoint.py | 23 ++++---- .../server/endpoint/projects_endpoint.py | 24 +++++++++ tableauserverclient/server/server.py | 24 ++++++--- test/http/test_http_requests.py | 52 ++++++++++++++++--- versioneer.py | 0 12 files changed, 156 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/pypi-smoke-tests.yml create mode 100644 samples/smoke_test.py mode change 100755 => 100644 versioneer.py diff --git a/.github/workflows/pypi-smoke-tests.yml b/.github/workflows/pypi-smoke-tests.yml new file mode 100644 index 000000000..eb6406573 --- /dev/null +++ b/.github/workflows/pypi-smoke-tests.yml @@ -0,0 +1,36 @@ +# This workflow will install TSC from pypi and validate that it runs. For more information see: +# https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Pypi smoke tests + +on: + workflow_dispatch: + schedule: + - cron: 0 11 * * * # Every day at 11AM UTC (7AM EST) + +permissions: + contents: read + +jobs: + build: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ['3.x'] + + runs-on: ${{ matrix.os }} + + steps: + - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: pip install + run: | + pip uninstall tableauserverclient + pip install tableauserverclient + - name: Launch app + run: | + python -c "import tableauserverclient as TSC + server = TSC.Server('http://example.com', use_server_version=False)" diff --git a/LICENSE b/LICENSE index 6222b2e80..22f90640f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2016 Tableau +Copyright (c) 2022 Tableau Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/samples/smoke_test.py b/samples/smoke_test.py new file mode 100644 index 000000000..f2dad1048 --- /dev/null +++ b/samples/smoke_test.py @@ -0,0 +1,8 @@ +# This sample verifies that tableau server client is installed +# and you can run it. It also shows the version of the client. + +import tableauserverclient as TSC + +server = TSC.Server("Fake-Server-Url", use_server_version=False) +print("Client details:") +print(TSC.server.endpoint.Endpoint._make_common_headers("fake-token", "any-content")) diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 7c1e6d705..212540d84 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -1,3 +1,4 @@ +from ._version import get_versions from .namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE from .models import ( BackgroundJobItem, diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 6ad0fda5a..24ba1d682 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -65,6 +65,6 @@ def credentials(self): } def __repr__(self): - return "".format( + return "(site={})".format( self.token_name, self.personal_access_token[:2] + "...", self.site_id ) diff --git a/tableauserverclient/models/tableau_types.py b/tableauserverclient/models/tableau_types.py index feaf02873..6ed77318f 100644 --- a/tableauserverclient/models/tableau_types.py +++ b/tableauserverclient/models/tableau_types.py @@ -11,9 +11,11 @@ class Resource: Database = "database" + Datarole = "datarole" Datasource = "datasource" Flow = "flow" Lens = "lens" + Metric = "metric" Project = "project" Table = "table" View = "view" diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 6baf399ed..68d75eaa8 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -28,7 +28,18 @@ def baseurl(self): def sign_in(self, auth_req): url = "{0}/{1}".format(self.baseurl, "signin") signin_req = RequestFactory.Auth.signin_req(auth_req) - server_response = self.parent_srv.session.post(url, data=signin_req, **self.parent_srv.http_options) + server_response = self.parent_srv.session.post( + url, data=signin_req, **self.parent_srv.http_options, allow_redirects=False + ) + # manually handle a redirect so that we send the correct POST request instead of GET + # this will make e.g http://online.tableau.com work to redirect to http://east.online.tableau.com + if server_response.status_code == 301: + server_response = self.parent_srv.session.post( + server_response.headers["Location"], + data=signin_req, + **self.parent_srv.http_options, + allow_redirects=False, + ) self.parent_srv._namespace.detect(server_response.content) self._check_status(server_response, url) parsed_response = fromstring(server_response.content) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 3cdc49322..a836b000d 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -12,11 +12,11 @@ EndpointUnavailableError, ) from ..query import QuerySet -from ... import helpers -from ..._version import get_versions +from ... import helpers, get_versions -__TSC_VERSION__ = get_versions()["version"] -del get_versions +if TYPE_CHECKING: + from ..server import Server + from requests import Response logger = logging.getLogger("tableau.endpoint") @@ -25,11 +25,9 @@ XML_CONTENT_TYPE = "text/xml" JSON_CONTENT_TYPE = "application/json" -USERAGENT_HEADER = "User-Agent" - -if TYPE_CHECKING: - from ..server import Server - from requests import Response +CONTENT_TYPE_HEADER = "content-type" +TABLEAU_AUTH_HEADER = "x-tableau-auth" +USER_AGENT_HEADER = "User-Agent" class Endpoint(object): @@ -38,12 +36,13 @@ def __init__(self, parent_srv: "Server"): @staticmethod def _make_common_headers(auth_token, content_type): + _client_version: Optional[str] = get_versions()["version"] headers = {} if auth_token is not None: - headers["x-tableau-auth"] = auth_token + headers[TABLEAU_AUTH_HEADER] = auth_token if content_type is not None: - headers["content-type"] = content_type - headers["User-Agent"] = "Tableau Server Client/{}".format(__TSC_VERSION__) + headers[CONTENT_TYPE_HEADER] = content_type + headers[USER_AGENT_HEADER] = "Tableau Server Client/{}".format(_client_version) return headers def _make_request( diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index e268d2011..7ccdcd775 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -99,6 +99,14 @@ def populate_workbook_default_permissions(self, item): def populate_datasource_default_permissions(self, item): self._default_permissions.populate_default_permissions(item, Resource.Datasource) + @api(version="3.2") + def populate_metric_default_permissions(self, item): + self._default_permissions.populate_default_permissions(item, Resource.Metric) + + @api(version="3.4") + def populate_datarole_default_permissions(self, item): + self._default_permissions.populate_default_permissions(item, Resource.Datarole) + @api(version="3.4") def populate_flow_default_permissions(self, item): self._default_permissions.populate_default_permissions(item, Resource.Flow) @@ -115,6 +123,14 @@ def update_workbook_default_permissions(self, item, rules): def update_datasource_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Resource.Datasource) + @api(version="3.2") + def update_metric_default_permissions(self, item, rules): + return self._default_permissions.update_default_permissions(item, rules, Resource.Metric) + + @api(version="3.4") + def update_datarole_default_permissions(self, item, rules): + return self._default_permissions.update_default_permissions(item, rules, Resource.Datarole) + @api(version="3.4") def update_flow_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Resource.Flow) @@ -131,6 +147,14 @@ def delete_workbook_default_permissions(self, item, rule): def delete_datasource_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Resource.Datasource) + @api(version="3.2") + def delete_metric_default_permissions(self, item, rule): + self._default_permissions.delete_default_permission(item, rule, Resource.Metric) + + @api(version="3.4") + def delete_datarole_default_permissions(self, item, rule): + self._default_permissions.delete_default_permission(item, rule, Resource.Datarole) + @api(version="3.4") def delete_flow_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Resource.Flow) diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index ebe11dac7..5e2dacf33 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -1,9 +1,10 @@ +import logging import warnings import requests import urllib3 -from defusedxml.ElementTree import fromstring +from defusedxml.ElementTree import fromstring, ParseError from packaging.version import Version from .endpoint import ( Sites, @@ -61,7 +62,7 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self._site_id = None self._user_id = None - self._server_address = server_address + self._server_address: str = server_address self._session_factory = session_factory or requests.session self.auth = Auth(self) @@ -103,10 +104,13 @@ def __init__(self, server_address, use_server_version=False, http_options=None, def validate_server_connection(self): try: - self._session.prepare_request(requests.Request("GET", url=self._server_address, params=self._http_options)) + if not self._server_address.startswith("http://") and not self._server_address.startswith("https://"): + self._server_address = "http://" + self._server_address + self._session.prepare_request( + requests.Request("GET", url=self._server_address, params=self._http_options) + ) except Exception as req_ex: - warnings.warn("Invalid server initialization\n {}".format(req_ex.__str__()), UserWarning) - print("==================") + raise ValueError("Invalid server initialization", req_ex) def __repr__(self): return " [Connection: {}, {}]".format(self.baseurl, self.server_info.serverInfo) @@ -140,7 +144,13 @@ def _set_auth(self, site_id, user_id, auth_token): def _get_legacy_version(self): response = self._session.get(self.server_address + "/auth?format=xml") - info_xml = fromstring(response.content) + try: + info_xml = fromstring(response.content) + except ParseError as parseError: + logging.getLogger("TSC.server").info( + "Could not read server version info. The server may not be running or configured." + ) + return self.version prod_version = info_xml.find(".//product_version").text version = _PRODUCT_TO_REST_VERSION.get(prod_version, "2.1") # 2.1 return version @@ -164,8 +174,6 @@ def use_server_version(self): def use_highest_version(self): self.use_server_version() - import warnings - warnings.warn("use use_server_version instead", DeprecationWarning) def check_at_least_version(self, target: str): diff --git a/test/http/test_http_requests.py b/test/http/test_http_requests.py index a5f4f4669..e96879277 100644 --- a/test/http/test_http_requests.py +++ b/test/http/test_http_requests.py @@ -1,22 +1,39 @@ import tableauserverclient as TSC import unittest import requests +import requests_mock -from requests_mock import adapter, mock +from unittest import mock from requests.exceptions import MissingSchema +# This method will be used by the mock to replace requests.get +def mocked_requests_get(*args, **kwargs): + class MockResponse: + def __init__(self, status_code): + self.content = ( + "" + "" + "0.31" + "0.31" + "2022.3" + "" + "" + ) + self.status_code = status_code + + return MockResponse(200) + + class ServerTests(unittest.TestCase): def test_init_server_model_empty_throws(self): with self.assertRaises(TypeError): server = TSC.Server() - def test_init_server_model_bad_server_name_complains(self): - # by default, it will just set the version to 2.3 + def test_init_server_model_no_protocol_defaults_htt(self): server = TSC.Server("fake-url") def test_init_server_model_valid_server_name_works(self): - # by default, it will just set the version to 2.3 server = TSC.Server("http://fake-url") def test_init_server_model_valid_https_server_name_works(self): @@ -24,18 +41,18 @@ def test_init_server_model_valid_https_server_name_works(self): server = TSC.Server("https://fake-url") def test_init_server_model_bad_server_name_not_version_check(self): - # by default, it will just set the version to 2.3 server = TSC.Server("fake-url", use_server_version=False) def test_init_server_model_bad_server_name_do_version_check(self): - with self.assertRaises(MissingSchema): + with self.assertRaises(requests.exceptions.ConnectionError): server = TSC.Server("fake-url", use_server_version=True) def test_init_server_model_bad_server_name_not_version_check_random_options(self): - # by default, it will just set the version to 2.3 + # with self.assertRaises(MissingSchema): server = TSC.Server("fake-url", use_server_version=False, http_options={"foo": 1}) def test_init_server_model_bad_server_name_not_version_check_real_options(self): + # with self.assertRaises(ValueError): server = TSC.Server("fake-url", use_server_version=False, http_options={"verify": False}) def test_http_options_skip_ssl_works(self): @@ -62,6 +79,25 @@ def test_http_options_not_sequence_fails(self): with self.assertRaises(ValueError): server.add_http_options({1, 2, 3}) + def test_validate_connection_http(self): + url = "http://cookies.com" + server = TSC.Server(url) + server.validate_server_connection() + self.assertEqual(url, server.server_address) + + def test_validate_connection_https(self): + url = "https://cookies.com" + server = TSC.Server(url) + server.validate_server_connection() + self.assertEqual(url, server.server_address) + + def test_validate_connection_no_protocol(self): + url = "cookies.com" + fixed_url = "http://cookies.com" + server = TSC.Server(url) + server.validate_server_connection() + self.assertEqual(fixed_url, server.server_address) + class SessionTests(unittest.TestCase): test_header = {"x-test": "true"} @@ -74,6 +110,6 @@ def session_factory(): def test_session_factory_adds_headers(self): test_request_bin = "http://capture-this-with-mock.com" - with mock() as m: + with requests_mock.mock() as m: m.get(url="http://capture-this-with-mock.com/api/2.4/serverInfo", request_headers=SessionTests.test_header) server = TSC.Server(test_request_bin, use_server_version=True, session_factory=SessionTests.session_factory) diff --git a/versioneer.py b/versioneer.py old mode 100755 new mode 100644 From 83e1069520669ad18f2cfca384bc74784c3bb75a Mon Sep 17 00:00:00 2001 From: Jac Date: Thu, 13 Oct 2022 20:53:17 -0700 Subject: [PATCH 020/296] Update publish-pypi.yml (#1130) * Update publish-pypi.yml Change from user saying to use prod to using prod whenever it runs on master --- .github/workflows/publish-pypi.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 33438bed8..63610be70 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -26,13 +26,15 @@ jobs: pip install -e .[test] build python -m build git describe --tag --dirty --always - - name: Publish distribution 📦 to Test PyPI + + - name: Publish distribution 📦 to Test PyPI # always run uses: pypa/gh-action-pypi-publish@release/v1 # license BSD-2 with: password: ${{ secrets.TEST_PYPI_API_TOKEN }} repository_url: https://test.pypi.org/legacy/ + - name: Publish distribution 📦 to PyPI - if: github.ref == 'refs/heads/master' + if: $GITHUB_REF == 'refs/heads/master' uses: pypa/gh-action-pypi-publish@release/v1 # license BSD-2 with: password: ${{ secrets.PYPI_API_TOKEN }} From fb3cd656e33ffd48fab365d17ce60c2766021ebf Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Thu, 22 Dec 2022 11:49:52 -0800 Subject: [PATCH 021/296] Fix publishing and mypy actions (#1158) * Fix usage of env var in publishing action file * Support old implicit optional behavior for mypy (https://github.com/hauntsaninja/no_implicit_optional) --- .github/workflows/meta-checks.yml | 2 +- .github/workflows/publish-pypi.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/meta-checks.yml b/.github/workflows/meta-checks.yml index 7ae27e6b8..3fcb852d1 100644 --- a/.github/workflows/meta-checks.yml +++ b/.github/workflows/meta-checks.yml @@ -32,4 +32,4 @@ jobs: - name: Run Mypy tests if: always() run: | - mypy --show-error-codes --disable-error-code misc --disable-error-code import tableauserverclient test + mypy --show-error-codes --disable-error-code misc --disable-error-code import --implicit-optional tableauserverclient test diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 63610be70..13e40899a 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -34,7 +34,7 @@ jobs: repository_url: https://test.pypi.org/legacy/ - name: Publish distribution 📦 to PyPI - if: $GITHUB_REF == 'refs/heads/master' + if: ${GITHUB_REF} == 'refs/heads/master' uses: pypa/gh-action-pypi-publish@release/v1 # license BSD-2 with: password: ${{ secrets.PYPI_API_TOKEN }} From c2ff85936246a69713b668f4210d5a3152feae51 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Thu, 22 Dec 2022 13:01:50 -0800 Subject: [PATCH 022/296] Fix github reference for publishing action (#1159) --- .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 13e40899a..34a7aa448 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -34,7 +34,7 @@ jobs: repository_url: https://test.pypi.org/legacy/ - name: Publish distribution 📦 to PyPI - if: ${GITHUB_REF} == 'refs/heads/master' + if: ${{ github.ref == 'refs/heads/master' }} uses: pypa/gh-action-pypi-publish@release/v1 # license BSD-2 with: password: ${{ secrets.PYPI_API_TOKEN }} From db441bd39c623812541e77315a46968675e209d8 Mon Sep 17 00:00:00 2001 From: Jac Date: Thu, 22 Dec 2022 14:36:08 -0800 Subject: [PATCH 023/296] run if master OR tag (#1160) --- .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 34a7aa448..b8a70e9c5 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -34,7 +34,7 @@ jobs: repository_url: https://test.pypi.org/legacy/ - name: Publish distribution 📦 to PyPI - if: ${{ github.ref == 'refs/heads/master' }} + if: ${{ github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v') }} uses: pypa/gh-action-pypi-publish@release/v1 # license BSD-2 with: password: ${{ secrets.PYPI_API_TOKEN }} From 97747b7b253d659de7871e0b3f20c0a1702b4c2a Mon Sep 17 00:00:00 2001 From: TrimPeachu <77048868+TrimPeachu@users.noreply.github.com> Date: Sat, 7 Jan 2023 00:38:23 +0100 Subject: [PATCH 024/296] Add logic to retrieve Datarole and Metric permissions (#1163) --- tableauserverclient/models/project_item.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index acb14ce91..a8430bfd0 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -34,6 +34,8 @@ def __init__( self._default_datasource_permissions = None self._default_flow_permissions = None self._default_lens_permissions = None + self._default_datarole_permissions = None + self._default_metric_permissions = None @property def content_permissions(self): @@ -79,6 +81,20 @@ def default_lens_permissions(self): raise UnpopulatedPropertyError(error) return self._default_lens_permissions() + @property + def default_datarole_permissions(self): + if self._default_datarole_permissions is None: + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) + return self._default_datarole_permissions() + + @property + def default_metric_permissions(self): + if self._default_metric_permissions is None: + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) + return self._default_metric_permissions() + @property def id(self) -> Optional[str]: return self._id From a29ba6cac78b77db0c22d84b539fc6db7fa1132a Mon Sep 17 00:00:00 2001 From: Jac Date: Mon, 13 Feb 2023 21:10:44 -0800 Subject: [PATCH 025/296] Create code-coverage.yml (#1190) copied from /tableau/tabcmd repo --- .github/workflows/code-coverage.yml | 38 +++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/code-coverage.yml diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml new file mode 100644 index 000000000..d393a06d5 --- /dev/null +++ b/.github/workflows/code-coverage.yml @@ -0,0 +1,38 @@ +name: Check Test Coverage + +on: + pull_request: + branches: + - development + +jobs: + build: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ['3.10'] + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e .[test] + + # https://github.com/marketplace/actions/pytest-coverage-comment + - name: Generate coverage report + run: pytest --junitxml=pytest.xml --cov=tableauserverclient tests/ | tee pytest-coverage.txt + + - name: Comment on pull request with coverage + uses: MishaKav/pytest-coverage-comment@main + with: + pytest-coverage-path: ./pytest-coverage.txt From 514cc1348bfc17eea8fae32743f5e95784b0e259 Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 14 Feb 2023 12:47:28 -0800 Subject: [PATCH 026/296] push code for 0.24 (#1168) * Allow injection of sessions (#1111) * show server info (#1118) * Fix bug in exposing ExcelRequestOptions and test (#1123) * Fix a few pylint errors (#1124) * fix behavior when url has no protocol (#1125) * Add permission control for Data Roles and Metrics (Issue #1063) (#1120) * add option to pass specific datasources (#1150) * allow user agent to be set by caller (#1166) * Fix issues with connections publishing workbooks (#1171) * Allow download to file-like objects (#1172) * Add updated_at to JobItem class (#1182) * fix revision references where xml returned does not match docs (#1176) * Do not create empty connections list (#1178) --------- Co-authored-by: Marwan Baghdad Co-authored-by: jorwoods Co-authored-by: Brian Cantoni Co-authored-by: TrimPeachu <77048868+TrimPeachu@users.noreply.github.com> Co-authored-by: Stu Tomlinson Co-authored-by: Jeremy Harris --- tableauserverclient/models/datasource_item.py | 2 +- tableauserverclient/models/job_item.py | 10 +- tableauserverclient/models/revision_item.py | 8 +- tableauserverclient/models/site_item.py | 6 +- tableauserverclient/models/workbook_item.py | 2 +- .../server/endpoint/datasources_endpoint.py | 97 ++++++---------- .../server/endpoint/endpoint.py | 54 +++++---- .../server/endpoint/exceptions.py | 3 +- .../server/endpoint/flows_endpoint.py | 106 +++++++++++++----- .../server/endpoint/permissions_endpoint.py | 4 +- .../server/endpoint/schedules_endpoint.py | 8 +- .../server/endpoint/users_endpoint.py | 12 +- .../server/endpoint/workbooks_endpoint.py | 96 +++++++--------- tableauserverclient/server/request_factory.py | 24 ++-- tableauserverclient/server/server.py | 21 ++-- test/assets/SampleFlow.tfl | Bin 0 -> 1884 bytes test/assets/datasource_revision.xml | 10 +- test/assets/flow_publish.xml | 10 ++ test/assets/workbook_revision.xml | 10 +- test/http/test_http_requests.py | 6 +- test/test_datasource.py | 12 ++ test/test_endpoint.py | 18 +++ test/test_flow.py | 83 +++++++++++++- test/test_job.py | 2 + test/test_workbook.py | 61 ++++++++++ 25 files changed, 439 insertions(+), 226 deletions(-) create mode 100644 test/assets/SampleFlow.tfl create mode 100644 test/assets/flow_publish.xml diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 37ec1449a..4a7a74c4b 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -34,7 +34,7 @@ class AskDataEnablement: Disabled = "Disabled" SiteDefault = "SiteDefault" - def __init__(self, project_id: str, name: str = None) -> None: + def __init__(self, project_id: str, name: Optional[str] = None) -> None: self._ask_data_enablement = None self._certified = None self._certification_note = None diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 39562cd45..a7490e705 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -34,6 +34,7 @@ def __init__( workbook_id: Optional[str] = None, datasource_id: Optional[str] = None, flow_run: Optional[FlowRunItem] = None, + updated_at: Optional["datetime.datetime"] = None, ): self._id = id_ self._type = job_type @@ -47,6 +48,7 @@ def __init__( self._workbook_id = workbook_id self._datasource_id = datasource_id self._flow_run = flow_run + self._updated_at = updated_at @property def id(self) -> str: @@ -113,9 +115,13 @@ def flow_run(self): def flow_run(self, value): self._flow_run = value + @property + def updated_at(self) -> Optional["datetime.datetime"]: + return self._updated_at + def __repr__(self): return ( - "".format(**self.__dict__) ) @@ -144,6 +150,7 @@ def _parse_element(cls, element, ns): datasource = element.find(".//t:datasource[@id]", namespaces=ns) datasource_id = datasource.get("id") if datasource is not None else None flow_run = None + updated_at = parse_datetime(element.get("updatedAt", None)) for flow_job in element.findall(".//t:runFlowJobType", namespaces=ns): flow_run = FlowRunItem() flow_run._id = flow_job.get("flowRunId", None) @@ -163,6 +170,7 @@ def _parse_element(cls, element, ns): workbook_id, datasource_id, flow_run, + updated_at, ) diff --git a/tableauserverclient/models/revision_item.py b/tableauserverclient/models/revision_item.py index a49be88a7..600d73168 100644 --- a/tableauserverclient/models/revision_item.py +++ b/tableauserverclient/models/revision_item.py @@ -67,10 +67,10 @@ def from_response(cls, resp: bytes, ns, resource_item) -> List["RevisionItem"]: revision_item._resource_id = resource_item.id revision_item._resource_name = resource_item.name revision_item._revision_number = revision_xml.get("revisionNumber", None) - revision_item._current = string_to_bool(revision_xml.get("isCurrent", "")) - revision_item._deleted = string_to_bool(revision_xml.get("isDeleted", "")) - revision_item._created_at = parse_datetime(revision_xml.get("createdAt", None)) - for user in revision_xml.findall(".//t:user", namespaces=ns): + revision_item._current = string_to_bool(revision_xml.get("current", "")) + revision_item._deleted = string_to_bool(revision_xml.get("deleted", "")) + revision_item._created_at = parse_datetime(revision_xml.get("publishedAt", None)) + for user in revision_xml.findall(".//t:publisher", namespaces=ns): revision_item._user_id = user.get("id", None) revision_item._user_name = user.get("name", None) diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index 8c9e8fe8e..e6bc3af24 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -50,9 +50,9 @@ def __init__( self, name: str, content_url: str, - admin_mode: str = None, - user_quota: int = None, - storage_quota: int = None, + admin_mode: Optional[str] = None, + user_quota: Optional[int] = None, + storage_quota: Optional[int] = None, disable_subscriptions: bool = False, subscribe_others_enabled: bool = True, revision_history_enabled: bool = False, diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 0d18e770d..6d9a21b6b 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -33,7 +33,7 @@ class WorkbookItem(object): - def __init__(self, project_id: str, name: str = None, show_tabs: bool = False) -> None: + def __init__(self, project_id: str, name: Optional[str] = None, show_tabs: bool = False) -> None: self._connections = None self._content_url = None self._webpage_url = None diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 022523aa4..9df7edfc8 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -31,22 +31,9 @@ ) from ...models import ConnectionCredentials, RevisionItem from ...models.job_item import JobItem -from ...models import ConnectionCredentials -io_types = (io.BytesIO, io.BufferedReader) - -from pathlib import Path -from typing import ( - List, - Mapping, - Optional, - Sequence, - Tuple, - TYPE_CHECKING, - Union, -) - -io_types = (io.BytesIO, io.BufferedReader) +io_types_r = (io.BytesIO, io.BufferedReader) +io_types_w = (io.BytesIO, io.BufferedWriter) # The maximum size of a file that can be published in a single request is 64MB FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB @@ -61,8 +48,10 @@ from .schedules_endpoint import AddResponse FilePath = Union[str, os.PathLike] -FileObject = Union[io.BufferedReader, io.BytesIO] -PathOrFile = Union[FilePath, FileObject] +FileObjectR = Union[io.BufferedReader, io.BytesIO] +FileObjectW = Union[io.BufferedWriter, io.BytesIO] +PathOrFileR = Union[FilePath, FileObjectR] +PathOrFileW = Union[FilePath, FileObjectW] class Datasources(QuerysetEndpoint): @@ -80,7 +69,7 @@ def baseurl(self) -> str: # Get all datasources @api(version="2.0") - def get(self, req_options: RequestOptions = None) -> Tuple[List[DatasourceItem], PaginationItem]: + def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[DatasourceItem], PaginationItem]: logger.info("Querying all datasources on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -135,39 +124,11 @@ def delete(self, datasource_id: str) -> None: def download( self, datasource_id: str, - filepath: FilePath = None, + filepath: Optional[PathOrFileW] = None, include_extract: bool = True, no_extract: Optional[bool] = None, ) -> str: - if not datasource_id: - error = "Datasource ID undefined." - raise ValueError(error) - url = "{0}/{1}/content".format(self.baseurl, datasource_id) - - 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" - - with closing(self.get_request(url, parameters={"stream": True})) as server_response: - _, params = cgi.parse_header(server_response.headers["Content-Disposition"]) - filename = to_filename(os.path.basename(params["filename"])) - - download_path = make_download_path(filepath, filename) - - with open(download_path, "wb") as f: - for chunk in server_response.iter_content(1024): # 1KB - f.write(chunk) - - logger.info("Downloaded datasource to {0} (ID: {1})".format(download_path, datasource_id)) - return os.path.abspath(download_path) + return self.download_revision(datasource_id, None, filepath, include_extract, no_extract) # Update datasource @api(version="2.0") @@ -232,10 +193,10 @@ def delete_extract(self, datasource_item: DatasourceItem) -> None: def publish( self, datasource_item: DatasourceItem, - file: PathOrFile, + file: PathOrFileR, mode: str, - connection_credentials: ConnectionCredentials = None, - connections: Sequence[ConnectionItem] = None, + connection_credentials: Optional[ConnectionCredentials] = None, + connections: Optional[Sequence[ConnectionItem]] = None, as_job: bool = False, ) -> Union[DatasourceItem, JobItem]: @@ -255,8 +216,7 @@ def publish( error = "Only {} files can be published as datasources.".format(", ".join(ALLOWED_FILE_EXTENSIONS)) raise ValueError(error) - elif isinstance(file, io_types): - + elif isinstance(file, io_types_r): if not datasource_item.name: error = "Datasource item must have a name when passing a file object" raise ValueError(error) @@ -302,7 +262,7 @@ def publish( if isinstance(file, (Path, str)): with open(file, "rb") as f: file_contents = f.read() - elif isinstance(file, io_types): + elif isinstance(file, io_types_r): file_contents = file.read() else: raise TypeError("file should be a filepath or file object.") @@ -433,14 +393,17 @@ def download_revision( self, datasource_id: str, revision_number: str, - filepath: Optional[PathOrFile] = None, + filepath: Optional[PathOrFileW] = None, include_extract: bool = True, no_extract: Optional[bool] = None, - ) -> str: + ) -> PathOrFileW: if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) - url = "{0}/{1}/revisions/{2}/content".format(self.baseurl, datasource_id, revision_number) + if revision_number is None: + 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 @@ -455,18 +418,22 @@ def download_revision( with closing(self.get_request(url, parameters={"stream": True})) as server_response: _, params = cgi.parse_header(server_response.headers["Content-Disposition"]) - filename = to_filename(os.path.basename(params["filename"])) - - download_path = make_download_path(filepath, filename) - - with open(download_path, "wb") as f: + if isinstance(filepath, io_types_w): for chunk in server_response.iter_content(1024): # 1KB - f.write(chunk) + filepath.write(chunk) + return_path = filepath + else: + filename = to_filename(os.path.basename(params["filename"])) + download_path = make_download_path(filepath, filename) + with open(download_path, "wb") as f: + for chunk in server_response.iter_content(1024): # 1KB + f.write(chunk) + return_path = os.path.abspath(download_path) logger.info( - "Downloaded datasource revision {0} to {1} (ID: {2})".format(revision_number, download_path, datasource_id) + "Downloaded datasource revision {0} to {1} (ID: {2})".format(revision_number, return_path, datasource_id) ) - return os.path.abspath(download_path) + return return_path @api(version="2.3") def delete_revision(self, datasource_id: str, revision_number: str) -> None: diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index a836b000d..b1a42b20c 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -3,7 +3,7 @@ from packaging.version import Version from functools import wraps from xml.etree.ElementTree import ParseError -from typing import Any, Callable, Dict, Optional, TYPE_CHECKING +from typing import Any, Callable, Dict, Optional, TYPE_CHECKING, Mapping from .exceptions import ( ServerResponseError, @@ -35,15 +35,35 @@ def __init__(self, parent_srv: "Server"): self.parent_srv = parent_srv @staticmethod - def _make_common_headers(auth_token, content_type): - _client_version: Optional[str] = get_versions()["version"] - headers = {} + def set_parameters(http_options, auth_token, content, content_type, parameters) -> Dict[str, Any]: + parameters = parameters or {} + parameters.update(http_options) + if "headers" not in parameters: + parameters["headers"] = {} + if auth_token is not None: - headers[TABLEAU_AUTH_HEADER] = auth_token + parameters["headers"][TABLEAU_AUTH_HEADER] = auth_token if content_type is not None: - headers[CONTENT_TYPE_HEADER] = content_type - headers[USER_AGENT_HEADER] = "Tableau Server Client/{}".format(_client_version) - return headers + parameters["headers"][CONTENT_TYPE_HEADER] = content_type + + Endpoint.set_user_agent(parameters) + if content is not None: + parameters["data"] = content + return parameters or {} + + @staticmethod + def set_user_agent(parameters): + if USER_AGENT_HEADER not in parameters["headers"]: + if USER_AGENT_HEADER in parameters: + parameters["headers"][USER_AGENT_HEADER] = parameters[USER_AGENT_HEADER] + else: + # only set the TSC user agent if not already populated + _client_version: Optional[str] = get_versions()["version"] + parameters["headers"][USER_AGENT_HEADER] = "Tableau Server Client/{}".format(_client_version) + + # result: parameters["headers"]["User-Agent"] is set + # return explicitly for testing only + return parameters def _make_request( self, @@ -54,18 +74,14 @@ def _make_request( content_type: Optional[str] = None, parameters: Optional[Dict[str, Any]] = None, ) -> "Response": - parameters = parameters or {} - if "headers" not in parameters: - parameters["headers"] = {} - parameters.update(self.parent_srv.http_options) - parameters["headers"].update(Endpoint._make_common_headers(auth_token, content_type)) - - if content is not None: - parameters["data"] = content + parameters = Endpoint.set_parameters( + self.parent_srv.http_options, auth_token, content, content_type, parameters + ) - logger.debug("request {}, url: {}".format(method.__name__, url)) + logger.debug("request {}, url: {}".format(method, url)) if content: - logger.debug("request content: {}".format(helpers.strings.redact_xml(content[:1000]))) + redacted = helpers.strings.redact_xml(content[:1000]) + logger.debug("request content: {}".format(redacted)) server_response = method(url, **parameters) self._check_status(server_response, url) @@ -78,7 +94,7 @@ def _make_request( return server_response - def _check_status(self, server_response, url: str = None): + def _check_status(self, server_response, url: Optional[str] = None): if server_response.status_code >= 500: raise InternalServerError(server_response, url) elif server_response.status_code not in Success_codes: diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 3ce0d5e92..d7b1d5ad2 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -1,4 +1,5 @@ from defusedxml.ElementTree import fromstring +from typing import Optional class TableauError(Exception): @@ -33,7 +34,7 @@ def from_response(cls, resp, ns, url=None): class InternalServerError(TableauError): - def __init__(self, server_response, request_url: str = None): + def __init__(self, server_response, request_url: Optional[str] = None): self.code = server_response.status_code self.content = server_response.content self.url = request_url or "server" diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 2c54d17c4..5b182111b 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -1,8 +1,10 @@ import cgi import copy +import io import logging import os from contextlib import closing +from pathlib import Path from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union from .dqw_endpoint import _DataQualityWarningEndpoint @@ -11,9 +13,17 @@ from .permissions_endpoint import _PermissionsEndpoint from .resource_tagger import _ResourceTagger from .. import RequestFactory, FlowItem, PaginationItem, ConnectionItem -from ...filesys_helpers import to_filename, make_download_path +from ...filesys_helpers import ( + to_filename, + make_download_path, + get_file_type, + get_file_object_size, +) from ...models.job_item import JobItem +io_types_r = (io.BytesIO, io.BufferedReader) +io_types_w = (io.BytesIO, io.BufferedWriter) + # The maximum size of a file that can be published in a single request is 64MB FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB @@ -29,6 +39,10 @@ FilePath = Union[str, os.PathLike] +FileObjectR = Union[io.BufferedReader, io.BytesIO] +FileObjectW = Union[io.BufferedWriter, io.BytesIO] +PathOrFileR = Union[FilePath, FileObjectR] +PathOrFileW = Union[FilePath, FileObjectW] class Flows(QuerysetEndpoint): @@ -94,7 +108,7 @@ def delete(self, flow_id: str) -> None: # Download 1 flow by id @api(version="3.3") - def download(self, flow_id: str, filepath: FilePath = None) -> str: + def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> PathOrFileW: if not flow_id: error = "Flow ID undefined." raise ValueError(error) @@ -102,16 +116,20 @@ def download(self, flow_id: str, filepath: FilePath = None) -> str: with closing(self.get_request(url, parameters={"stream": True})) as server_response: _, params = cgi.parse_header(server_response.headers["Content-Disposition"]) - filename = to_filename(os.path.basename(params["filename"])) - - download_path = make_download_path(filepath, filename) - - with open(download_path, "wb") as f: + if isinstance(filepath, io_types_w): for chunk in server_response.iter_content(1024): # 1KB - f.write(chunk) - - logger.info("Downloaded flow to {0} (ID: {1})".format(download_path, flow_id)) - return os.path.abspath(download_path) + filepath.write(chunk) + return_path = filepath + else: + filename = to_filename(os.path.basename(params["filename"])) + download_path = make_download_path(filepath, filename) + with open(download_path, "wb") as f: + for chunk in server_response.iter_content(1024): # 1KB + f.write(chunk) + return_path = os.path.abspath(download_path) + + logger.info("Downloaded flow to {0} (ID: {1})".format(return_path, flow_id)) + return return_path # Update flow @api(version="3.3") @@ -153,24 +171,49 @@ def refresh(self, flow_item: FlowItem) -> JobItem: # Publish flow @api(version="3.3") def publish( - self, flow_item: FlowItem, file_path: FilePath, mode: str, connections: Optional[List[ConnectionItem]] = None + self, flow_item: FlowItem, file: PathOrFileR, mode: str, connections: Optional[List[ConnectionItem]] = None ) -> FlowItem: - if not os.path.isfile(file_path): - error = "File path does not lead to an existing file." - raise IOError(error) if not mode or not hasattr(self.parent_srv.PublishMode, mode): error = "Invalid mode defined." raise ValueError(error) - filename = os.path.basename(file_path) - file_extension = os.path.splitext(filename)[1][1:] + if isinstance(file, (str, os.PathLike)): + if not os.path.isfile(file): + error = "File path does not lead to an existing file." + raise IOError(error) + + filename = os.path.basename(file) + file_extension = os.path.splitext(filename)[1][1:] + file_size = os.path.getsize(file) + + # If name is not defined, grab the name from the file to publish + if not flow_item.name: + flow_item.name = os.path.splitext(filename)[0] + if file_extension not in ALLOWED_FILE_EXTENSIONS: + error = "Only {} files can be published as flows.".format(", ".join(ALLOWED_FILE_EXTENSIONS)) + raise ValueError(error) + + elif isinstance(file, io_types_r): + if not flow_item.name: + error = "Flow item must have a name when passing a file object" + raise ValueError(error) + + file_type = get_file_type(file) + if file_type == "zip": + file_extension = "tflx" + elif file_type == "xml": + file_extension = "tfl" + else: + error = "Unsupported file type {}!".format(file_type) + raise ValueError(error) + + # Generate filename for file object. + # This is needed when publishing the flow in a single request + filename = "{}.{}".format(flow_item.name, file_extension) + file_size = get_file_object_size(file) - # If name is not defined, grab the name from the file to publish - if not flow_item.name: - flow_item.name = os.path.splitext(filename)[0] - if file_extension not in ALLOWED_FILE_EXTENSIONS: - error = "Only {} files can be published as flows.".format(", ".join(ALLOWED_FILE_EXTENSIONS)) - raise ValueError(error) + else: + raise TypeError("file should be a filepath or file object.") # Construct the url with the defined mode url = "{0}?flowType={1}".format(self.baseurl, file_extension) @@ -178,15 +221,24 @@ def publish( url += "&{0}=true".format(mode.lower()) # Determine if chunking is required (64MB is the limit for single upload method) - if os.path.getsize(file_path) >= FILESIZE_LIMIT: + if file_size >= FILESIZE_LIMIT: logger.info("Publishing {0} to server with chunking method (flow over 64MB)".format(filename)) - upload_session_id = self.parent_srv.fileuploads.upload(file_path) + upload_session_id = self.parent_srv.fileuploads.upload(file) url = "{0}&uploadSessionId={1}".format(url, upload_session_id) xml_request, content_type = RequestFactory.Flow.publish_req_chunked(flow_item, connections) else: logger.info("Publishing {0} to server".format(filename)) - with open(file_path, "rb") as f: - file_contents = f.read() + + if isinstance(file, (str, Path)): + with open(file, "rb") as f: + file_contents = f.read() + + elif isinstance(file, io_types_r): + file_contents = file.read() + + else: + raise TypeError("file should be a filepath or file object.") + xml_request, content_type = RequestFactory.Flow.publish_req(flow_item, filename, file_contents, connections) # Send the publishing request to server diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py index f7c2f9f13..e3e9af2a6 100644 --- a/tableauserverclient/server/endpoint/permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/permissions_endpoint.py @@ -6,7 +6,7 @@ from .exceptions import MissingRequiredFieldError from ...models import TableauItem -from typing import Callable, TYPE_CHECKING, List, Union +from typing import Optional, Callable, TYPE_CHECKING, List, Union logger = logging.getLogger(__name__) @@ -82,7 +82,7 @@ def permission_fetcher(): item._set_permissions(permission_fetcher) logger.info("Populated permissions for item (ID: {0})".format(item.id)) - def _get_permissions(self, item: TableauItem, req_options: "RequestOptions" = None): + def _get_permissions(self, item: TableauItem, req_options: Optional["RequestOptions"] = None): url = "{0}/{1}/permissions".format(self.owner_baseurl(), item.id) server_response = self.get_request(url, req_options) permissions = PermissionsRule.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index 21c828989..65a55bcb6 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -85,10 +85,10 @@ def create(self, schedule_item: ScheduleItem) -> ScheduleItem: def add_to_schedule( self, schedule_id: str, - workbook: "WorkbookItem" = None, - datasource: "DatasourceItem" = None, - flow: "FlowItem" = None, - task_type: str = None, + workbook: Optional["WorkbookItem"] = None, + datasource: Optional["DatasourceItem"] = None, + flow: Optional["FlowItem"] = None, + task_type: Optional[str] = None, ) -> List[AddResponse]: # There doesn't seem to be a good reason to allow one item of each type? diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 28406ab71..3faf4d173 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -21,7 +21,7 @@ def baseurl(self) -> str: # Gets all users @api(version="2.0") - def get(self, req_options: RequestOptions = None) -> Tuple[List[UserItem], PaginationItem]: + def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[UserItem], PaginationItem]: logger.info("Querying all users on site") if req_options is None: @@ -47,7 +47,7 @@ def get_by_id(self, user_id: str) -> UserItem: # Update user @api(version="2.0") - def update(self, user_item: UserItem, password: str = None) -> UserItem: + def update(self, user_item: UserItem, password: Optional[str] = None) -> UserItem: if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) @@ -122,7 +122,7 @@ def create_from_file(self, filepath: str) -> Tuple[List[UserItem], List[Tuple[Us # Get workbooks for user @api(version="2.0") - def populate_workbooks(self, user_item: UserItem, req_options: RequestOptions = None) -> None: + def populate_workbooks(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) @@ -133,7 +133,7 @@ def wb_pager(): user_item._set_workbooks(wb_pager) def _get_wbs_for_user( - self, user_item: UserItem, req_options: RequestOptions = None + self, user_item: UserItem, req_options: Optional[RequestOptions] = None ) -> Tuple[List[WorkbookItem], PaginationItem]: url = "{0}/{1}/workbooks".format(self.baseurl, user_item.id) server_response = self.get_request(url, req_options) @@ -147,7 +147,7 @@ def populate_favorites(self, user_item: UserItem) -> None: # Get groups for user @api(version="3.7") - def populate_groups(self, user_item: UserItem, req_options: RequestOptions = None) -> None: + def populate_groups(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) @@ -161,7 +161,7 @@ def groups_for_user_pager(): user_item._set_groups(groups_for_user_pager) def _get_groups_for_user( - self, user_item: UserItem, req_options: RequestOptions = None + self, user_item: UserItem, req_options: Optional[RequestOptions] = None ) -> Tuple[List[GroupItem], PaginationItem]: url = "{0}/{1}/groups".format(self.baseurl, user_item.id) server_response = self.get_request(url, req_options) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 4d7a4a2b5..8cca4150a 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -45,6 +45,9 @@ from ...models.connection_credentials import ConnectionCredentials from .schedules_endpoint import AddResponse +io_types_r = (io.BytesIO, io.BufferedReader) +io_types_w = (io.BytesIO, io.BufferedWriter) + # The maximum size of a file that can be published in a single request is 64MB FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB @@ -53,7 +56,10 @@ logger = logging.getLogger("tableau.endpoint.workbooks") FilePath = Union[str, os.PathLike] FileObject = Union[io.BufferedReader, io.BytesIO] -PathOrFile = Union[FilePath, FileObject] +FileObjectR = Union[io.BufferedReader, io.BytesIO] +FileObjectW = Union[io.BufferedWriter, io.BytesIO] +PathOrFileR = Union[FilePath, FileObjectR] +PathOrFileW = Union[FilePath, FileObjectW] class Workbooks(QuerysetEndpoint): @@ -117,12 +123,13 @@ def create_extract( # delete all the extracts on 1 workbook @api(version="3.3") - def delete_extract(self, workbook_item: WorkbookItem, includeAll: bool = True) -> None: + def delete_extract(self, workbook_item: WorkbookItem, includeAll: bool = True, datasources=None) -> JobItem: id_ = getattr(workbook_item, "id", workbook_item) url = "{0}/{1}/deleteExtract".format(self.baseurl, id_) - datasource_req = RequestFactory.Workbook.embedded_extract_req(includeAll, None) + datasource_req = RequestFactory.Workbook.embedded_extract_req(includeAll, datasources) server_response = self.post_request(url, datasource_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] + return new_job # Delete 1 workbook by id @api(version="2.0") @@ -178,38 +185,11 @@ def update_connection(self, workbook_item: WorkbookItem, connection_item: Connec def download( self, workbook_id: str, - filepath: FilePath = None, + filepath: Optional[PathOrFileW] = None, include_extract: bool = True, no_extract: Optional[bool] = None, ) -> str: - if not workbook_id: - error = "Workbook ID undefined." - raise ValueError(error) - url = "{0}/{1}/content".format(self.baseurl, workbook_id) - - 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" - - with closing(self.get_request(url, parameters={"stream": True})) as server_response: - _, params = cgi.parse_header(server_response.headers["Content-Disposition"]) - filename = to_filename(os.path.basename(params["filename"])) - - download_path = make_download_path(filepath, filename) - - with open(download_path, "wb") as f: - for chunk in server_response.iter_content(1024): # 1KB - f.write(chunk) - logger.info("Downloaded workbook to {0} (ID: {1})".format(download_path, workbook_id)) - return os.path.abspath(download_path) + return self.download_revision(workbook_id, None, filepath, include_extract, no_extract) # Get all views of workbook @api(version="2.0") @@ -250,7 +230,7 @@ def connection_fetcher(): logger.info("Populated connections for workbook (ID: {0})".format(workbook_item.id)) def _get_workbook_connections( - self, workbook_item: WorkbookItem, req_options: "RequestOptions" = None + self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None ) -> List[ConnectionItem]: url = "{0}/{1}/connections".format(self.baseurl, workbook_item.id) server_response = self.get_request(url, req_options) @@ -259,7 +239,7 @@ def _get_workbook_connections( # Get the pdf of the entire workbook if its tabs are enabled, pdf of the default view if its tabs are disabled @api(version="3.4") - def populate_pdf(self, workbook_item: WorkbookItem, req_options: "RequestOptions" = None) -> None: + def populate_pdf(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None) -> None: if not workbook_item.id: error = "Workbook item missing ID." raise MissingRequiredFieldError(error) @@ -331,7 +311,7 @@ def delete_permission(self, item, capability_item): def publish( self, workbook_item: WorkbookItem, - file: PathOrFile, + file: PathOrFileR, mode: str, connection_credentials: Optional["ConnectionCredentials"] = None, connections: Optional[Sequence[ConnectionItem]] = None, @@ -349,7 +329,6 @@ def publish( ) if isinstance(file, (str, os.PathLike)): - # Expect file to be a filepath if not os.path.isfile(file): error = "File path does not lead to an existing file." raise IOError(error) @@ -365,12 +344,12 @@ def publish( error = "Only {} files can be published as workbooks.".format(", ".join(ALLOWED_FILE_EXTENSIONS)) raise ValueError(error) - elif isinstance(file, (io.BytesIO, io.BufferedReader)): - # Expect file to be a file object - file_size = get_file_object_size(file) + elif isinstance(file, io_types_r): + if not workbook_item.name: + error = "Workbook item must have a name when passing a file object" + raise ValueError(error) file_type = get_file_type(file) - if file_type == "zip": file_extension = "twbx" elif file_type == "xml": @@ -379,13 +358,10 @@ def publish( error = "Unsupported file type {}!".format(file_type) raise ValueError(error) - if not workbook_item.name: - error = "Workbook item must have a name when passing a file object" - raise ValueError(error) - # Generate filename for file object. # This is needed when publishing the workbook in a single request filename = "{}.{}".format(workbook_item.name, file_extension) + file_size = get_file_object_size(file) else: raise TypeError("file should be a filepath or file object.") @@ -427,7 +403,7 @@ def publish( with open(file, "rb") as f: file_contents = f.read() - elif isinstance(file, (io.BytesIO, io.BufferedReader)): + elif isinstance(file, io_types_r): file_contents = file.read() else: @@ -488,14 +464,17 @@ def download_revision( self, workbook_id: str, revision_number: str, - filepath: Optional[PathOrFile] = None, + filepath: Optional[PathOrFileW] = None, include_extract: bool = True, no_extract: Optional[bool] = None, - ) -> str: + ) -> PathOrFileW: if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) - url = "{0}/{1}/revisions/{2}/content".format(self.baseurl, workbook_id, revision_number) + if revision_number is None: + url = "{0}/{1}/content".format(self.baseurl, workbook_id) + 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 @@ -511,17 +490,22 @@ def download_revision( with closing(self.get_request(url, parameters={"stream": True})) as server_response: _, params = cgi.parse_header(server_response.headers["Content-Disposition"]) - filename = to_filename(os.path.basename(params["filename"])) - - download_path = make_download_path(filepath, filename) - - with open(download_path, "wb") as f: + if isinstance(filepath, io_types_w): for chunk in server_response.iter_content(1024): # 1KB - f.write(chunk) + filepath.write(chunk) + return_path = filepath + else: + filename = to_filename(os.path.basename(params["filename"])) + download_path = make_download_path(filepath, filename) + with open(download_path, "wb") as f: + for chunk in server_response.iter_content(1024): # 1KB + f.write(chunk) + return_path = os.path.abspath(download_path) + logger.info( - "Downloaded workbook revision {0} to {1} (ID: {2})".format(revision_number, download_path, workbook_id) + "Downloaded workbook revision {0} to {1} (ID: {2})".format(revision_number, return_path, workbook_id) ) - return os.path.abspath(download_path) + return return_path @api(version="2.3") def delete_revision(self, workbook_id: str, revision_number: str) -> None: diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index aad8ca074..720eb4085 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -7,6 +7,7 @@ from tableauserverclient.models.metric_item import MetricItem +from ..models import ConnectionCredentials from ..models import ConnectionItem from ..models import DataAlertItem from ..models import FlowItem @@ -55,6 +56,13 @@ def _add_connections_element(connections_element, connection): connection_element.attrib["serverPort"] = connection.server_port if connection.connection_credentials: connection_credentials = connection.connection_credentials + elif connection.username is not None and connection.password is not None and connection.embed_password is not None: + connection_credentials = ConnectionCredentials( + connection.username, connection.password, embed=connection.embed_password + ) + else: + connection_credentials = None + if connection_credentials: _add_credentials_element(connection_element, connection_credentials) @@ -66,7 +74,7 @@ def _add_hiddenview_element(views_element, view_name): def _add_credentials_element(parent_element, connection_credentials): credentials_element = ET.SubElement(parent_element, "connectionCredentials") - if not connection_credentials.password or not connection_credentials.name: + if connection_credentials.password is None or connection_credentials.name is None: raise ValueError("Connection Credentials must have a name and password") credentials_element.attrib["name"] = connection_credentials.name credentials_element.attrib["password"] = connection_credentials.password @@ -174,10 +182,10 @@ def _generate_xml(self, datasource_item, connection_credentials=None, connection 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: + if connection_credentials is not None and connection_credentials != False: _add_credentials_element(datasource_element, connection_credentials) - if connections is not None: + if connections is not None and connections != False and len(connections) > 0: connections_element = ET.SubElement(datasource_element, "connections") for connection in connections: _add_connections_element(connections_element, connection) @@ -329,7 +337,7 @@ def _generate_xml(self, flow_item: "FlowItem", connections: Optional[List["Conne project_element = ET.SubElement(flow_element, "project") project_element.attrib["id"] = flow_item.project_id - if connections is not None: + if connections is not None and connections != False: connections_element = ET.SubElement(flow_element, "connections") for connection in connections: _add_connections_element(connections_element, connection) @@ -575,7 +583,7 @@ def add_flow_req(self, id_: Optional[str], task_type: str = TaskItem.Type.RunFlo class SiteRequest(object): - def update_req(self, site_item: "SiteItem", parent_srv: "Server" = None): + def update_req(self, site_item: "SiteItem", parent_srv: Optional["Server"] = None): xml_request = ET.Element("tsRequest") site_element = ET.SubElement(xml_request, "site") if site_item.name: @@ -683,7 +691,7 @@ def update_req(self, site_item: "SiteItem", parent_srv: "Server" = None): return ET.tostring(xml_request) # server: the site request model changes based on api version - def create_req(self, site_item: "SiteItem", parent_srv: "Server" = None): + def create_req(self, site_item: "SiteItem", parent_srv: Optional["Server"] = None): xml_request = ET.Element("tsRequest") site_element = ET.SubElement(xml_request, "site") site_element.attrib["name"] = site_item.name @@ -896,10 +904,10 @@ def _generate_xml( 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: + if connection_credentials is not None and connection_credentials != False: _add_credentials_element(workbook_element, connection_credentials) - if connections is not None: + if connections is not None and connections != False and len(connections) > 0: connections_element = ET.SubElement(workbook_element, "connections") for connection in connections: _add_connections_element(connections_element, connection) diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 5e2dacf33..d2a8b933b 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -31,6 +31,7 @@ Fileuploads, FlowRuns, Metrics, + Endpoint, ) from .endpoint.exceptions import ( ServerInfoEndpointNotFoundError, @@ -62,6 +63,10 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self._site_id = None self._user_id = None + # TODO: this needs to change to default to https, but without breaking existing code + if not server_address.startswith("http://") and not server_address.startswith("https://"): + server_address = "http://" + server_address + self._server_address: str = server_address self._session_factory = session_factory or requests.session @@ -96,21 +101,17 @@ def __init__(self, server_address, use_server_version=False, http_options=None, if http_options: self.add_http_options(http_options) - self.validate_server_connection() + self.validate_connection_settings() # does not make an actual outgoing request self.version = default_server_version if use_server_version: self.use_server_version() # this makes a server call - def validate_server_connection(self): + def validate_connection_settings(self): try: - if not self._server_address.startswith("http://") and not self._server_address.startswith("https://"): - self._server_address = "http://" + self._server_address - self._session.prepare_request( - requests.Request("GET", url=self._server_address, params=self._http_options) - ) + Endpoint(self).set_parameters(self._http_options, None, None, None, None) except Exception as req_ex: - raise ValueError("Invalid server initialization", req_ex) + raise ValueError("Server connection settings not valid", req_ex) def __repr__(self): return " [Connection: {}, {}]".format(self.baseurl, self.server_info.serverInfo) @@ -143,10 +144,12 @@ def _set_auth(self, site_id, user_id, auth_token): self._auth_token = auth_token def _get_legacy_version(self): - response = self._session.get(self.server_address + "/auth?format=xml") + dest = Endpoint(self) + response = dest._make_request(method=self.session.get, url=self.server_address + "/auth?format=xml") try: info_xml = fromstring(response.content) except ParseError as parseError: + logging.getLogger("TSC.server").info(parseError) logging.getLogger("TSC.server").info( "Could not read server version info. The server may not be running or configured." ) diff --git a/test/assets/SampleFlow.tfl b/test/assets/SampleFlow.tfl new file mode 100644 index 0000000000000000000000000000000000000000..c46d9ced964c70d7601e58f4b4d3002412dc4dc1 GIT binary patch literal 1884 zcmWIWW@Zs#;Nak3Q1jLeV?YA@Kz2%IaY0UEWpHXqNoHPpacciYzC#KkuHWleU*2&H z5BI%uWy!Il76O$~XI?L6l**O*7rWrbg&p&XcYZe%>zS%suUX5OyJXR%+#9>PLM}C} zQ>yiFD7?Z{lCaoaa>|j5F6UxQHSbS4az{|y-ty}O-?uC7S!Y^3apt||RW+;T*zLVx zwdDa!K{-3cgiICg;#i=PDt$jX%sx3u&PBtv@OF#H*N*ze;#C|*yJ}3W zGYc(?KR=h!WVsNQct2=Lc$xMc@c?gjj>t!=@9Y42sfm$+ApozZa}!gGON#P+Q%e$4 z5=#=7hD7HJyNlF45AXStvP9tMN$$JuP0dcME;G6XoK~;=enT$ed`^0rFSE<4y<%L` zPq{HMG9A%1@#J20spV8|V7%EMhJTJrIhSRAb1zBLFB94-+BALYw>j@?o?B11oc>*5 z%Ie3Td*(CgaV)k>S$N1JN42EARA*aQOxZ>c)%mtvuM^H}R2Ba%qhup~ewu@q)9NJk zoax4$3Q26!^&ew-JoT==5nisF{n>N- z{py?bUz3V|8lI}(VRpa%N0G#*eZ_nKC**!UUfr3z`1`8*-^=ULb<=hnc%$DR9afTW z_sZ5a+I-r-xZLO4>+;yYIX4?~1;=bWP9XrRQ}QcfEbN zDAl*d&}`rP^~=4(|Fyn-+qJCv`oxEys@T`A|8sM9tJK3!yZa*I6Q89YkkNY)-&=W` zOMHpro4rD>ManL|IMT%Vq(O4NU!dAUE!WC7S2|VroE^5Va#>r*k`cuSb~j5?W( zDgL+EOYV0GRa+=u`7L0VUidOi+C<*>L{I5$wKvZ$1<$crwfx7LyY;n)eLl9g_RoEE zHtkHn!*5f)7D6@u3Bwtc_gn7VY8MrB$&w%2rfoRYIx9j*q{)&;Gu@^4X%SyQ_AbJL5j%?}UpvLaT$+9HwN?@mLu@-M@5u3(x%%-j{SP zue`gdcCXLY57TGeR4dV|J^5?bq+RjzQ%d>0KTYM{z3yE7oLPtd`P-Vd{*Cf^_}|Z0 z(bDvnb^E!uGk>##^5Vl@^Edv?3=Gn2`0^qPFt?`VJ8JJ@x|K!EMRZhyfdvkO-Y zSMF;R|F}i`rnAvZhiPULO`5!4?vBnD7rFWR`}zOIhr0~x3~!c%XnddZ_NKu_^-YD3 z3jbR08%{~Mm0VA>Mqf)+6@bNpME!e zv9i{0@4J#`>QC3d{CxCjuyakY?P~9ew;I!$t0nF?T;jD-U9(9cVfW-iCZ)UZj}eul&{f>DsNU!t&v`*KcgrUhmm1T1j3D1{F>Q`J1N2)F z-jYZR>U;O1#Da{FjMChsyu{2Lz2dTT-@db4%?1K3@u@e1xsR?{)*<$IVZKtX57Wo? z9TLC4`7UXfON(sFZ+p4z?g7J}mBAS^ueS>r9#cA%#OW5|q9W8NaCi1vC(o9It;W+t zr=68eEVr_o?ySh@=*zMxZ^QehI>Qw4hsF2e9skBp2$Y!noojLFnqa>t3HK!*7@WDi zqs8;#&tHvSU4yE%Z@-%5IC;vlukj#nS?9+aOa?mo5D*7=Gct)VAnFz5!WUGppaQg7 z23<39YC+Zf6=)7xX^pNKIj}(q3IQ5{Tu|~t*MaO0P$VEgJu5^9W|RbYvjUSc0|N^X KegM*8U>*P_VF4)s literal 0 HcmV?d00001 diff --git a/test/assets/datasource_revision.xml b/test/assets/datasource_revision.xml index 598c8ad45..8cadafc8f 100644 --- a/test/assets/datasource_revision.xml +++ b/test/assets/datasource_revision.xml @@ -2,13 +2,13 @@ - - + + - + - - + + \ No newline at end of file diff --git a/test/assets/flow_publish.xml b/test/assets/flow_publish.xml new file mode 100644 index 000000000..55af88d11 --- /dev/null +++ b/test/assets/flow_publish.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/test/assets/workbook_revision.xml b/test/assets/workbook_revision.xml index 598c8ad45..8cadafc8f 100644 --- a/test/assets/workbook_revision.xml +++ b/test/assets/workbook_revision.xml @@ -2,13 +2,13 @@ - - + + - + - - + + \ No newline at end of file diff --git a/test/http/test_http_requests.py b/test/http/test_http_requests.py index e96879277..bf9292dec 100644 --- a/test/http/test_http_requests.py +++ b/test/http/test_http_requests.py @@ -82,20 +82,20 @@ def test_http_options_not_sequence_fails(self): def test_validate_connection_http(self): url = "http://cookies.com" server = TSC.Server(url) - server.validate_server_connection() + server.validate_connection_settings() self.assertEqual(url, server.server_address) def test_validate_connection_https(self): url = "https://cookies.com" server = TSC.Server(url) - server.validate_server_connection() + server.validate_connection_settings() self.assertEqual(url, server.server_address) def test_validate_connection_no_protocol(self): url = "cookies.com" fixed_url = "http://cookies.com" server = TSC.Server(url) - server.validate_server_connection() + server.validate_connection_settings() self.assertEqual(fixed_url, server.server_address) diff --git a/test/test_datasource.py b/test/test_datasource.py index 46378201f..e486eec33 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -470,6 +470,18 @@ def test_download(self) -> None: self.assertTrue(os.path.exists(file_path)) os.remove(file_path) + def test_download_object(self) -> None: + with BytesIO() as file_object: + with requests_mock.mock() as m: + m.get( + self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content", + headers={"Content-Disposition": 'name="tableau_datasource"; filename="Sample datasource.tds"'}, + ) + file_path = self.server.datasources.download( + "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", filepath=file_object + ) + self.assertTrue(isinstance(file_path, BytesIO)) + def test_download_sanitizes_name(self) -> None: filename = "Name,With,Commas.tds" disposition = 'name="tableau_workbook"; filename="{}"'.format(filename) diff --git a/test/test_endpoint.py b/test/test_endpoint.py index e583a9188..5b6324cab 100644 --- a/test/test_endpoint.py +++ b/test/test_endpoint.py @@ -38,3 +38,21 @@ class FakeResponse(object): server_response = FakeResponse() log = endpoint.log_response_safely(server_response) self.assertTrue(log.find("[Truncated File Contents]") > 0, log) + + def test_set_user_agent_from_options_headers(self): + params = {"User-Agent": "1", "headers": {"User-Agent": "2"}} + result = TSC.server.Endpoint.set_user_agent(params) + # it should use the value under 'headers' if more than one is given + print(result) + print(result["headers"]["User-Agent"]) + self.assertTrue(result["headers"]["User-Agent"] == "2") + + def test_set_user_agent_from_options(self): + params = {"headers": {"User-Agent": "2"}} + result = TSC.server.Endpoint.set_user_agent(params) + self.assertTrue(result["headers"]["User-Agent"] == "2") + + def test_set_user_agent_when_blank(self): + params = {"headers": {}} + result = TSC.server.Endpoint.set_user_agent(params) + self.assertTrue(result["headers"]["User-Agent"].startswith("Tableau Server Client")) diff --git a/test/test_flow.py b/test/test_flow.py index 269bc2f7e..bbd8a39d3 100644 --- a/test/test_flow.py +++ b/test/test_flow.py @@ -1,16 +1,21 @@ +import os +import requests_mock import unittest -import requests_mock +from io import BytesIO import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime from ._utils import read_xml_asset, asset -GET_XML = "flow_get.xml" -POPULATE_CONNECTIONS_XML = "flow_populate_connections.xml" -POPULATE_PERMISSIONS_XML = "flow_populate_permissions.xml" -UPDATE_XML = "flow_update.xml" -REFRESH_XML = "flow_refresh.xml" +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") + +GET_XML = os.path.join(TEST_ASSET_DIR, "flow_get.xml") +POPULATE_CONNECTIONS_XML = os.path.join(TEST_ASSET_DIR, "flow_populate_connections.xml") +POPULATE_PERMISSIONS_XML = os.path.join(TEST_ASSET_DIR, "flow_populate_permissions.xml") +PUBLISH_XML = os.path.join(TEST_ASSET_DIR, "flow_publish.xml") +UPDATE_XML = os.path.join(TEST_ASSET_DIR, "flow_update.xml") +REFRESH_XML = os.path.join(TEST_ASSET_DIR, "flow_refresh.xml") class FlowTests(unittest.TestCase): @@ -24,6 +29,26 @@ def setUp(self) -> None: self.baseurl = self.server.flows.baseurl + def test_download(self) -> None: + with requests_mock.mock() as m: + m.get( + self.baseurl + "/587daa37-b84d-4400-a9a2-aa90e0be7837/content", + headers={"Content-Disposition": 'name="tableau_flow"; filename="FlowOne.tfl"'}, + ) + file_path = self.server.flows.download("587daa37-b84d-4400-a9a2-aa90e0be7837") + self.assertTrue(os.path.exists(file_path)) + os.remove(file_path) + + def test_download_object(self) -> None: + with BytesIO() as file_object: + with requests_mock.mock() as m: + m.get( + self.baseurl + "/587daa37-b84d-4400-a9a2-aa90e0be7837/content", + headers={"Content-Disposition": 'name="tableau_flow"; filename="FlowOne.tfl"'}, + ) + file_path = self.server.flows.download("587daa37-b84d-4400-a9a2-aa90e0be7837", filepath=file_object) + self.assertTrue(isinstance(file_path, BytesIO)) + def test_get(self) -> None: response_xml = read_xml_asset(GET_XML) with requests_mock.mock() as m: @@ -116,6 +141,52 @@ def test_populate_permissions(self) -> None: }, ) + def test_publish(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_flow = TSC.FlowItem(name="SampleFlow", project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") + + sample_flow = os.path.join(TEST_ASSET_DIR, "SampleFlow.tfl") + publish_mode = self.server.PublishMode.CreateNew + + new_flow = self.server.flows.publish(new_flow, sample_flow, publish_mode) + + self.assertEqual("2457c468-1b24-461a-8f95-a461b3209d32", new_flow.id) + self.assertEqual("SampleFlow", new_flow.name) + self.assertEqual("2023-01-13T09:50:55Z", format_datetime(new_flow.created_at)) + self.assertEqual("2023-01-13T09:50:55Z", format_datetime(new_flow.updated_at)) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", new_flow.project_id) + self.assertEqual("default", new_flow.project_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", new_flow.owner_id) + + def test_publish_file_object(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_flow = TSC.FlowItem(name="SampleFlow", project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") + + sample_flow = os.path.join(TEST_ASSET_DIR, "SampleFlow.tfl") + publish_mode = self.server.PublishMode.CreateNew + + with open(sample_flow, "rb") as fp: + + publish_mode = self.server.PublishMode.CreateNew + + new_flow = self.server.flows.publish(new_flow, fp, publish_mode) + + self.assertEqual("2457c468-1b24-461a-8f95-a461b3209d32", new_flow.id) + self.assertEqual("SampleFlow", new_flow.name) + self.assertEqual("2023-01-13T09:50:55Z", format_datetime(new_flow.created_at)) + self.assertEqual("2023-01-13T09:50:55Z", format_datetime(new_flow.updated_at)) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", new_flow.project_id) + self.assertEqual("default", new_flow.project_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", new_flow.owner_id) + def test_refresh(self): with open(asset(REFRESH_XML), "rb") as f: response_xml = f.read().decode("utf-8") diff --git a/test/test_job.py b/test/test_job.py index 19a93e808..83edadaef 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -53,8 +53,10 @@ def test_get_by_id(self) -> None: with requests_mock.mock() as m: m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) job = self.server.jobs.get_by_id(job_id) + updated_at = datetime(2020, 5, 13, 20, 25, 18, tzinfo=utc) self.assertEqual(job_id, job.id) + self.assertEqual(updated_at, job.updated_at) self.assertListEqual(job.notes, ["Job detail notes"]) def test_get_before_signin(self) -> None: diff --git a/test/test_workbook.py b/test/test_workbook.py index db7f0723b..2e5de9369 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -267,6 +267,16 @@ def test_download(self) -> None: self.assertTrue(os.path.exists(file_path)) os.remove(file_path) + def test_download_object(self) -> None: + with BytesIO() as file_object: + with requests_mock.mock() as m: + m.get( + self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", + headers={"Content-Disposition": 'name="tableau_workbook"; filename="RESTAPISample.twbx"'}, + ) + file_path = self.server.workbooks.download("1f951daf-4061-451a-9df1-69a8062664f2", filepath=file_object) + self.assertTrue(isinstance(file_path, BytesIO)) + def test_download_sanitizes_name(self) -> None: filename = "Name,With,Commas.twbx" disposition = 'name="tableau_workbook"; filename="{}"'.format(filename) @@ -748,6 +758,30 @@ def test_publish_multi_connection(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_multi_connection_flat(self) -> None: + new_workbook = TSC.WorkbookItem( + name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" + ) + connection1 = TSC.ConnectionItem() + connection1.server_address = "mysql.test.com" + connection1.username = "test" + connection1.password = "secret" + connection1.embed_password = True + connection2 = TSC.ConnectionItem() + connection2.server_address = "pgsql.test.com" + connection2.username = "test" + connection2.password = "secret" + connection2.embed_password = True + + response = RequestFactory.Workbook._generate_xml(new_workbook, connections=[connection1, connection2]) + # Can't use ConnectionItem parser due to xml namespace problems + connection_results = fromstring(response).findall(".//connection") + + self.assertEqual(connection_results[0].get("serverAddress", None), "mysql.test.com") + self.assertEqual(connection_results[0].find("connectionCredentials").get("name", None), "test") # type: ignore[union-attr] + 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" @@ -762,6 +796,33 @@ def test_publish_single_connection(self) -> None: 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" From ccdd790c592c6e8cba882e26d13dd2083a9f828a Mon Sep 17 00:00:00 2001 From: r-richmond Date: Mon, 20 Feb 2023 16:16:22 -0800 Subject: [PATCH 027/296] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20Allow=20more=20rec?= =?UTF-8?q?ent=20version=20of=20packaging=20(#1196)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit re-format to match new black standards --- pyproject.toml | 2 +- samples/add_default_permission.py | 1 - samples/create_group.py | 1 - samples/create_schedules.py | 1 - samples/explore_datasource.py | 1 - samples/explore_site.py | 1 - samples/explore_webhooks.py | 3 --- samples/explore_workbook.py | 2 -- samples/extracts.py | 2 -- samples/filter_sort_groups.py | 1 - samples/filter_sort_projects.py | 1 - samples/initialize_server.py | 2 -- samples/login.py | 1 - samples/move_workbook_projects.py | 1 - samples/move_workbook_sites.py | 2 -- samples/pagination_sample.py | 1 - samples/publish_workbook.py | 2 -- samples/query_permissions.py | 1 - tableauserverclient/models/connection_item.py | 1 - tableauserverclient/models/group_item.py | 1 - tableauserverclient/models/interval_item.py | 2 -- tableauserverclient/models/property_decorators.py | 1 - tableauserverclient/models/table_item.py | 1 - tableauserverclient/models/user_item.py | 1 - tableauserverclient/server/endpoint/datasources_endpoint.py | 1 - tableauserverclient/server/endpoint/schedules_endpoint.py | 1 - tableauserverclient/server/endpoint/workbooks_endpoint.py | 1 - tableauserverclient/server/pager.py | 1 - test/test_datasource.py | 1 - test/test_endpoint.py | 1 - test/test_filesys_helpers.py | 6 ------ test/test_flow.py | 1 - test/test_project.py | 1 - test/test_site_model.py | 1 - test/test_user_model.py | 1 - test/test_workbook.py | 5 ----- 36 files changed, 1 insertion(+), 53 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 840c062e2..c9672462a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ readme = "README.md" dependencies = [ 'defusedxml>=0.7.1', - 'packaging~=21.3', + 'packaging>=22.0', # bumping to minimum version required by black 'requests>=2.28', 'urllib3~=1.26.8', ] diff --git a/samples/add_default_permission.py b/samples/add_default_permission.py index 829190359..8a87c1fd6 100644 --- a/samples/add_default_permission.py +++ b/samples/add_default_permission.py @@ -46,7 +46,6 @@ def main(): tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - # Create a sample project project = TSC.ProjectItem("sample_project") project = server.projects.create(project) diff --git a/samples/create_group.py b/samples/create_group.py index d5cf712db..2229f7f26 100644 --- a/samples/create_group.py +++ b/samples/create_group.py @@ -18,7 +18,6 @@ def main(): - parser = argparse.ArgumentParser(description="Creates a sample user group.") # Common options; please keep those in sync across all samples parser.add_argument("--server", "-s", required=True, help="server address") diff --git a/samples/create_schedules.py b/samples/create_schedules.py index 87b43dbca..f193352de 100644 --- a/samples/create_schedules.py +++ b/samples/create_schedules.py @@ -15,7 +15,6 @@ 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", required=True, help="server address") diff --git a/samples/explore_datasource.py b/samples/explore_datasource.py index 014a274ef..aafbe167c 100644 --- a/samples/explore_datasource.py +++ b/samples/explore_datasource.py @@ -16,7 +16,6 @@ def main(): - parser = argparse.ArgumentParser(description="Explore datasource functions supported by the Server API.") # Common options; please keep those in sync across all samples parser.add_argument("--server", "-s", required=True, help="server address") diff --git a/samples/explore_site.py b/samples/explore_site.py index 8c4abd9d3..a181abfec 100644 --- a/samples/explore_site.py +++ b/samples/explore_site.py @@ -12,7 +12,6 @@ def main(): - parser = argparse.ArgumentParser(description="Explore site updates by the Server API.") # Common options; please keep those in sync across all samples parser.add_argument("--server", "-s", required=True, help="server address") diff --git a/samples/explore_webhooks.py b/samples/explore_webhooks.py index 764fb0904..47e59ac06 100644 --- a/samples/explore_webhooks.py +++ b/samples/explore_webhooks.py @@ -17,7 +17,6 @@ def main(): - parser = argparse.ArgumentParser(description="Explore webhook functions supported by the Server API.") # Common options; please keep those in sync across all samples parser.add_argument("--server", "-s", required=True, help="server address") @@ -49,10 +48,8 @@ def main(): tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - # Create webhook if create flag is set (-create, -c) if args.create: - new_webhook = TSC.WebhookItem() new_webhook.name = args.create new_webhook.url = "https://ifttt.com/maker-url" diff --git a/samples/explore_workbook.py b/samples/explore_workbook.py index a5a337653..355319971 100644 --- a/samples/explore_workbook.py +++ b/samples/explore_workbook.py @@ -17,7 +17,6 @@ def main(): - parser = argparse.ArgumentParser(description="Explore workbook functions supported by the Server API.") # Common options; please keep those in sync across all samples parser.add_argument("--server", "-s", required=True, help="server address") @@ -52,7 +51,6 @@ def main(): tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - # Publish workbook if publish flag is set (-publish, -p) overwrite_true = TSC.Server.PublishMode.Overwrite if args.publish: diff --git a/samples/extracts.py b/samples/extracts.py index e5879a825..c77da89d0 100644 --- a/samples/extracts.py +++ b/samples/extracts.py @@ -17,7 +17,6 @@ def main(): - parser = argparse.ArgumentParser(description="Explore extract functions supported by the Server API.") # Common options; please keep those in sync across all samples parser.add_argument("--server", "-s", required=True, help="server address") @@ -50,7 +49,6 @@ def main(): server.add_http_options({"verify": False}) server.use_server_version() with server.auth.sign_in(tableau_auth): - # Gets all workbook items all_workbooks, pagination_item = server.workbooks.get() print("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) diff --git a/samples/filter_sort_groups.py b/samples/filter_sort_groups.py index c63764134..984d8d344 100644 --- a/samples/filter_sort_groups.py +++ b/samples/filter_sort_groups.py @@ -53,7 +53,6 @@ def main(): tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - group_name = "SALES NORTHWEST" # Try to create a group named "SALES NORTHWEST" create_example_group(group_name, server) diff --git a/samples/filter_sort_projects.py b/samples/filter_sort_projects.py index bd43cd209..608f472ba 100644 --- a/samples/filter_sort_projects.py +++ b/samples/filter_sort_projects.py @@ -18,7 +18,6 @@ def create_example_project( description="Project created for testing", server=None, ): - new_project = TSC.ProjectItem(name=name, content_permissions=content_permissions, description=description) try: server.projects.create(new_project) diff --git a/samples/initialize_server.py b/samples/initialize_server.py index 21b243013..e7ed0139f 100644 --- a/samples/initialize_server.py +++ b/samples/initialize_server.py @@ -45,7 +45,6 @@ def main(): tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - ################################################################################ # Step 2: Create the site we need only if it doesn't exist ################################################################################ @@ -75,7 +74,6 @@ def main(): tableau_auth.site_id = args.site_id with server_upload.auth.sign_in(tableau_auth): - ################################################################################ # Step 4: Create the project we need only if it doesn't exist ################################################################################ diff --git a/samples/login.py b/samples/login.py index f0ff9ad49..f3e9d77dc 100644 --- a/samples/login.py +++ b/samples/login.py @@ -48,7 +48,6 @@ def sample_define_common_options(parser): def sample_connect_to_server(args): - if args.username: # Trying to authenticate using username and password. password = args.password or getpass.getpass("Password: ") diff --git a/samples/move_workbook_projects.py b/samples/move_workbook_projects.py index 884c7eab1..be49ec23b 100644 --- a/samples/move_workbook_projects.py +++ b/samples/move_workbook_projects.py @@ -15,7 +15,6 @@ def main(): - parser = argparse.ArgumentParser(description="Move one workbook from the default project to another.") # Common options; please keep those in sync across all samples parser.add_argument("--server", "-s", required=True, help="server address") diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index a2d11bdfe..3feb62be2 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -16,7 +16,6 @@ def main(): - parser = argparse.ArgumentParser( description="Move one workbook from the" "default project of the default site to" @@ -84,7 +83,6 @@ def main(): # Signing into another site requires another server object # because of the different auth token and site ID. with dest_server.auth.sign_in(tableau_auth): - # Step 5: Create a new workbook item and publish workbook. Note that # an empty project_id will publish to the 'Default' project. new_workbook = TSC.WorkbookItem(name=args.workbook_name, project_id="") diff --git a/samples/pagination_sample.py b/samples/pagination_sample.py index e194f59f5..b55fef320 100644 --- a/samples/pagination_sample.py +++ b/samples/pagination_sample.py @@ -18,7 +18,6 @@ def main(): - parser = argparse.ArgumentParser(description="Demonstrate pagination on the list of workbooks on the server.") # Common options; please keep those in sync across all samples parser.add_argument("--server", "-s", required=True, help="server address") diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index a0bf1794b..f0edc380c 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -22,7 +22,6 @@ def main(): - parser = argparse.ArgumentParser(description="Publish a workbook to server.") # Common options; please keep those in sync across all samples parser.add_argument("--server", "-s", required=True, help="server address") @@ -55,7 +54,6 @@ def main(): tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - # Step 2: Get all the projects on server, then look for the default one. all_projects, pagination_item = server.projects.get() default_project = next((project for project in all_projects if project.is_default()), None) diff --git a/samples/query_permissions.py b/samples/query_permissions.py index 0c285d4c3..7106da934 100644 --- a/samples/query_permissions.py +++ b/samples/query_permissions.py @@ -44,7 +44,6 @@ def main(): tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - # Mapping to grab the handler for the user-inputted resource type endpoint = { "workbook": server.workbooks, diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index ed7733076..a170c5300 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -85,7 +85,6 @@ def from_xml_element(cls, parsed_response, ns) -> List["ConnectionItem"]: connection_credentials = connection_xml.find(".//t:connectionCredentials", namespaces=ns) if connection_credentials is not None: - connection_item.connection_credentials = ConnectionCredentials.from_xml_element( connection_credentials, ns ) diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index eb03b1b5d..a9cb2dcce 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -12,7 +12,6 @@ class GroupItem(object): - tag_name: str = "group" class LicenseMode: diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index cf5e70353..25b6d09d7 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -27,7 +27,6 @@ class Day: class HourlyInterval(object): def __init__(self, start_time, end_time, interval_value): - self.start_time = start_time self.end_time = end_time self.interval = interval_value @@ -70,7 +69,6 @@ def interval(self, interval): self._interval = interval def _interval_type_pairs(self): - # We use fractional hours for the two minute-based intervals. # Need to convert to minutes from hours here if self.interval in {0.25, 0.5}: diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index 2d7e01557..af8883290 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -101,7 +101,6 @@ def wrapper(self, value): def property_matches(regex_to_match, error): - compiled_re = re.compile(regex_to_match) def wrapper(func): diff --git a/tableauserverclient/models/table_item.py b/tableauserverclient/models/table_item.py index 93edac63c..7fbaa32d2 100644 --- a/tableauserverclient/models/table_item.py +++ b/tableauserverclient/models/table_item.py @@ -140,7 +140,6 @@ def from_response(cls, resp, ns): @staticmethod def _parse_element(table_xml, ns): - table_values = table_xml.attrib.copy() contact = table_xml.find(".//t:contact", namespaces=ns) diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 032841dc7..c19fd4f97 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -21,7 +21,6 @@ class UserItem(object): - tag_name: str = "user" class Roles: diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 9df7edfc8..97c39d1bb 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -199,7 +199,6 @@ def publish( connections: Optional[Sequence[ConnectionItem]] = None, as_job: bool = False, ) -> Union[DatasourceItem, JobItem]: - if isinstance(file, (os.PathLike, str)): if not os.path.isfile(file): error = "File path does not lead to an existing file." diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index 65a55bcb6..3010eeb3a 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -90,7 +90,6 @@ def add_to_schedule( flow: Optional["FlowItem"] = None, task_type: Optional[str] = None, ) -> List[AddResponse]: - # There doesn't seem to be a good reason to allow one item of each type? if workbook and datasource: warnings.warn("Passing in multiple items for add_to_schedule will be deprecated", PendingDeprecationWarning) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 8cca4150a..b7df3fcbb 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -319,7 +319,6 @@ def publish( hidden_views: Optional[Sequence[str]] = None, skip_connection_check: bool = False, ): - if connection_credentials is not None: import warnings diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index 2de84b4d1..b65d75ae5 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -13,7 +13,6 @@ class Pager(object): """ def __init__(self, endpoint, request_opts=None, **kwargs): - if hasattr(endpoint, "get"): # The simpliest case is to take an Endpoint and call its get endpoint = partial(endpoint.get, **kwargs) diff --git a/test/test_datasource.py b/test/test_datasource.py index e486eec33..4f3529762 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -542,7 +542,6 @@ def test_publish_hyper_file_object_raises_exception(self) -> None: ) def test_publish_tde_file_object_raises_exception(self) -> None: - new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "test") tds_asset = asset(os.path.join("Data", "Tableau Samples", "World Indicators.tde")) with open(tds_asset, "rb") as file_object: diff --git a/test/test_endpoint.py b/test/test_endpoint.py index 5b6324cab..0d8ae84f2 100644 --- a/test/test_endpoint.py +++ b/test/test_endpoint.py @@ -29,7 +29,6 @@ def test_get_request_stream(self) -> None: def test_binary_log_truncated(self): class FakeResponse(object): - headers = {"Content-Type": "application/octet-stream"} content = b"\x1337" * 1000 status_code = 200 diff --git a/test/test_filesys_helpers.py b/test/test_filesys_helpers.py index 645c5d372..4c8fb0f9f 100644 --- a/test/test_filesys_helpers.py +++ b/test/test_filesys_helpers.py @@ -10,7 +10,6 @@ class FilesysTests(unittest.TestCase): def test_get_file_size_returns_correct_size(self): - target_size = 1000 # bytes with BytesIO() as f: @@ -21,14 +20,12 @@ def test_get_file_size_returns_correct_size(self): self.assertEqual(file_size, target_size) def test_get_file_size_returns_zero_for_empty_file(self): - with BytesIO() as f: file_size = get_file_object_size(f) self.assertEqual(file_size, 0) def test_get_file_size_coincides_with_built_in_method(self): - asset_path = asset("SampleWB.twbx") target_size = os.path.getsize(asset_path) with open(asset_path, "rb") as f: @@ -37,7 +34,6 @@ def test_get_file_size_coincides_with_built_in_method(self): self.assertEqual(file_size, target_size) def test_get_file_type_identifies_a_zip_file(self): - with BytesIO() as file_object: with ZipFile(file_object, "w") as zf: with BytesIO() as stream: @@ -59,7 +55,6 @@ def test_get_file_type_identifies_twbx_as_zip_file(self): self.assertEqual(file_type, "zip") def test_get_file_type_identifies_xml_file(self): - root = ET.Element("root") child = ET.SubElement(root, "child") child.text = "This is a child element" @@ -95,7 +90,6 @@ def test_get_file_type_identifies_tde_file(self): self.assertEqual(file_type, "tde") def test_get_file_type_handles_unknown_file_type(self): - # Create a dummy png file with BytesIO() as file_object: png_signature = bytes.fromhex("89504E470D0A1A0A") diff --git a/test/test_flow.py b/test/test_flow.py index bbd8a39d3..d10641809 100644 --- a/test/test_flow.py +++ b/test/test_flow.py @@ -174,7 +174,6 @@ def test_publish_file_object(self) -> None: publish_mode = self.server.PublishMode.CreateNew with open(sample_flow, "rb") as fp: - publish_mode = self.server.PublishMode.CreateNew new_flow = self.server.flows.publish(new_flow, fp, publish_mode) diff --git a/test/test_project.py b/test/test_project.py index 48e6005af..3c75a0d3c 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -139,7 +139,6 @@ def test_update_missing_id(self) -> None: self.assertRaises(TSC.MissingRequiredFieldError, self.server.projects.update, single_project) def test_create(self) -> None: - with open(CREATE_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: diff --git a/test/test_site_model.py b/test/test_site_model.py index eb086f5af..f62eb66f0 100644 --- a/test/test_site_model.py +++ b/test/test_site_model.py @@ -22,7 +22,6 @@ def test_invalid_admin_mode(self): site.admin_mode = "Hello" def test_invalid_content_url(self): - with self.assertRaises(ValueError): site = TSC.SiteItem(name="蚵仔煎", content_url="蚵仔煎") diff --git a/test/test_user_model.py b/test/test_user_model.py index 32d808f52..fcb9b7f90 100644 --- a/test/test_user_model.py +++ b/test/test_user_model.py @@ -32,7 +32,6 @@ def test_invalid_site_role(self): class UserDataTest(unittest.TestCase): - logger = logging.getLogger("UserDataTest") role_inputs = [ diff --git a/test/test_workbook.py b/test/test_workbook.py index 2e5de9369..8711ba15e 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -525,7 +525,6 @@ def test_publish_a_packaged_file_object(self) -> None: sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") with open(sample_workbook, "rb") as fp: - publish_mode = self.server.PublishMode.CreateNew new_workbook = self.server.workbooks.publish(new_workbook, fp, publish_mode) @@ -545,7 +544,6 @@ def test_publish_a_packaged_file_object(self) -> None: self.assertEqual("RESTAPISample_0/sheets/GDPpercapita", new_workbook.views[0].content_url) def test_publish_non_packeged_file_object(self) -> None: - with open(PUBLISH_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: @@ -558,7 +556,6 @@ def test_publish_non_packeged_file_object(self) -> None: sample_workbook = os.path.join(TEST_ASSET_DIR, "RESTAPISample.twb") with open(sample_workbook, "rb") as fp: - publish_mode = self.server.PublishMode.CreateNew new_workbook = self.server.workbooks.publish(new_workbook, fp, publish_mode) @@ -715,7 +712,6 @@ def test_publish_unnamed_file_object(self) -> None: new_workbook = TSC.WorkbookItem("test") with open(os.path.join(TEST_ASSET_DIR, "SampleWB.twbx"), "rb") as f: - self.assertRaises( ValueError, self.server.workbooks.publish, new_workbook, f, self.server.PublishMode.CreateNew ) @@ -724,7 +720,6 @@ def test_publish_non_bytes_file_object(self) -> None: new_workbook = TSC.WorkbookItem("test") with open(os.path.join(TEST_ASSET_DIR, "SampleWB.twbx")) as f: - self.assertRaises( TypeError, self.server.workbooks.publish, new_workbook, f, self.server.PublishMode.CreateNew ) From e4fbe41560fbd2314c9c7b8b8a169164dd15185f Mon Sep 17 00:00:00 2001 From: Jac Date: Thu, 16 Mar 2023 11:05:21 -0700 Subject: [PATCH 028/296] Push code for 0.25 with custom views (#1206) * Implement custom view objects (#1195) * Fix bug in update-datasources before 3.15 (#1203) (fixes #1072) * catch exceptions from ServerInfo (#1204) * add query-tagging attribute to connection (#1202) (add explanation for why it doesn't work on hyper) --------- Co-authored-by: Marwan Baghdad Co-authored-by: jorwoods Co-authored-by: Brian Cantoni Co-authored-by: TrimPeachu <77048868+TrimPeachu@users.noreply.github.com> Co-authored-by: Stu Tomlinson Co-authored-by: Jeremy Harris --- .github/workflows/code-coverage.yml | 2 +- pyproject.toml | 2 +- samples/explore_workbook.py | 29 ++++ tableauserverclient/__init__.py | 39 +---- tableauserverclient/models/__init__.py | 3 + tableauserverclient/models/connection_item.py | 30 +++- .../models/custom_view_item.py | 156 ++++++++++++++++++ tableauserverclient/models/data_alert_item.py | 20 +-- tableauserverclient/models/datasource_item.py | 44 +++-- tableauserverclient/models/dqw_item.py | 2 +- tableauserverclient/models/flow_item.py | 38 ++--- tableauserverclient/models/flow_run_item.py | 21 +-- tableauserverclient/models/group_item.py | 2 +- tableauserverclient/models/job_item.py | 36 ++-- tableauserverclient/models/metric_item.py | 17 +- .../models/permissions_item.py | 12 +- tableauserverclient/models/project_item.py | 8 +- .../models/property_decorators.py | 2 +- tableauserverclient/models/revision_item.py | 12 +- tableauserverclient/models/schedule_item.py | 2 +- .../models/server_info_item.py | 10 +- tableauserverclient/models/site_item.py | 1 + tableauserverclient/models/tableau_auth.py | 2 + tableauserverclient/models/tableau_types.py | 14 +- tableauserverclient/models/tag_item.py | 3 +- tableauserverclient/models/task_item.py | 2 +- tableauserverclient/models/user_item.py | 25 ++- tableauserverclient/models/view_item.py | 29 ++-- tableauserverclient/models/workbook_item.py | 38 ++--- tableauserverclient/server/__init__.py | 52 +----- .../server/endpoint/__init__.py | 1 + .../server/endpoint/custom_views_endpoint.py | 104 ++++++++++++ .../data_acceleration_report_endpoint.py | 2 +- .../server/endpoint/data_alert_endpoint.py | 3 +- .../server/endpoint/databases_endpoint.py | 3 +- .../server/endpoint/datasources_endpoint.py | 53 +++--- .../endpoint/default_permissions_endpoint.py | 4 +- .../server/endpoint/dqw_endpoint.py | 3 +- .../server/endpoint/endpoint.py | 14 +- .../server/endpoint/favorites_endpoint.py | 8 +- .../server/endpoint/fileuploads_endpoint.py | 4 +- .../server/endpoint/flow_runs_endpoint.py | 4 +- .../server/endpoint/flows_endpoint.py | 11 +- .../server/endpoint/groups_endpoint.py | 3 +- .../server/endpoint/jobs_endpoint.py | 4 +- .../server/endpoint/metrics_endpoint.py | 5 +- .../server/endpoint/permissions_endpoint.py | 6 +- .../server/endpoint/projects_endpoint.py | 3 +- .../server/endpoint/resource_tagger.py | 4 +- .../server/endpoint/schedules_endpoint.py | 3 +- .../server/endpoint/server_info_endpoint.py | 8 +- .../server/endpoint/sites_endpoint.py | 3 +- .../server/endpoint/subscriptions_endpoint.py | 3 +- .../server/endpoint/tables_endpoint.py | 3 +- .../server/endpoint/tasks_endpoint.py | 3 +- .../server/endpoint/users_endpoint.py | 9 +- .../server/endpoint/views_endpoint.py | 2 +- .../server/endpoint/webhooks_endpoint.py | 4 +- .../server/endpoint/workbooks_endpoint.py | 25 +-- tableauserverclient/server/request_factory.py | 32 ++-- tableauserverclient/server/request_options.py | 2 +- tableauserverclient/server/server.py | 49 +++--- test/assets/custom_view_get.xml | 16 ++ test/assets/custom_view_get_id.xml | 8 + test/assets/custom_view_update.xml | 8 + test/assets/server_info_get.xml | 4 +- test/http/test_http_requests.py | 8 +- test/models/_models.py | 61 +++++++ test/models/test_repr.py | 40 +++++ test/test_connection_.py | 34 ++++ test/test_custom_view.py | 133 +++++++++++++++ test/test_datasource_model.py | 11 +- test/test_server_info.py | 11 +- test/test_user_model.py | 10 -- test/test_workbook.py | 7 +- 75 files changed, 963 insertions(+), 426 deletions(-) create mode 100644 tableauserverclient/models/custom_view_item.py create mode 100644 tableauserverclient/server/endpoint/custom_views_endpoint.py create mode 100644 test/assets/custom_view_get.xml create mode 100644 test/assets/custom_view_get_id.xml create mode 100644 test/assets/custom_view_update.xml create mode 100644 test/models/_models.py create mode 100644 test/models/test_repr.py create mode 100644 test/test_connection_.py create mode 100644 test/test_custom_view.py diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index d393a06d5..6d74c5c38 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -10,7 +10,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-latest] python-version: ['3.10'] runs-on: ${{ matrix.os }} diff --git a/pyproject.toml b/pyproject.toml index c9672462a..ee793ec41 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ classifiers = [ repository = "https://github.com/tableau/server-client-python" [project.optional-dependencies] -test = ["argparse", "black", "mock", "mypy", "pytest>=7.0", "requests-mock>=1.0,<2.0"] +test = ["argparse", "black", "mock", "mypy", "pytest>=7.0", "pytest-subtests", "requests-mock>=1.0,<2.0"] [tool.black] line-length = 120 diff --git a/samples/explore_workbook.py b/samples/explore_workbook.py index 355319971..f242ace70 100644 --- a/samples/explore_workbook.py +++ b/samples/explore_workbook.py @@ -72,6 +72,10 @@ def main(): if all_workbooks: # Pick one workbook from the list sample_workbook = all_workbooks[0] + sample_workbook.name = "Name me something cooler" + sample_workbook.description = "That doesn't work" + updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook) + print(updated.name, updated.description) # Populate views server.workbooks.populate_views(sample_workbook) @@ -125,6 +129,31 @@ def main(): f.write(sample_workbook.preview_image) print("\nDownloaded preview image of workbook to {}".format(os.path.abspath(args.preview_image))) + # get custom views + cvs, _ = server.custom_views.get() + for c in cvs: + print(c) + + # for the last custom view in the list + + # update the name + # note that this will fail if the name is already changed to this value + changed: TSC.CustomViewItem(id=c.id, name="I was updated by tsc") + verified_change = server.custom_views.update(changed) + print(verified_change) + + # export as image. Filters etc could be added here as usual + server.custom_views.populate_image(c) + filename = c.id + "-image-export.png" + with open(filename, "wb") as f: + f.write(c.image) + print("saved to " + filename) + + if args.delete: + print("deleting {}".format(c.id)) + unlucky = TSC.CustomViewItem(c.id) + server.custom_views.delete(unlucky.id) + if __name__ == "__main__": main() diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 212540d84..03e484372 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -1,43 +1,6 @@ from ._version import get_versions from .namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE -from .models import ( - BackgroundJobItem, - ColumnItem, - ConnectionCredentials, - ConnectionItem, - DQWItem, - DailyInterval, - DataAlertItem, - DatabaseItem, - DatasourceItem, - FlowItem, - FlowRunItem, - GroupItem, - HourlyInterval, - IntervalItem, - JobItem, - MetricItem, - MonthlyInterval, - PaginationItem, - Permission, - PermissionsRule, - PersonalAccessTokenAuth, - ProjectItem, - RevisionItem, - ScheduleItem, - SiteItem, - SubscriptionItem, - TableItem, - TableauAuth, - Target, - TaskItem, - UnpopulatedPropertyError, - UserItem, - ViewItem, - WebhookItem, - WeeklyInterval, - WorkbookItem, -) +from .models import * from .server import ( CSVRequestOptions, ExcelRequestOptions, diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 58e5ed6d1..b4a52f753 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -1,6 +1,7 @@ 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 @@ -8,6 +9,7 @@ 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 @@ -31,6 +33,7 @@ from .table_item import TableItem from .tableau_auth import Credentials, TableauAuth, PersonalAccessTokenAuth 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 diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index a170c5300..4ed06b831 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -1,10 +1,10 @@ -from typing import TYPE_CHECKING, List, Optional +import logging +from typing import List, Optional + from defusedxml.ElementTree import fromstring from .connection_credentials import ConnectionCredentials - -if TYPE_CHECKING: - from tableauserverclient.models.connection_credentials import ConnectionCredentials +from .property_decorators import property_is_boolean class ConnectionItem(object): @@ -18,7 +18,8 @@ def __init__(self): self.server_address: Optional[str] = None self.server_port: Optional[str] = None self.username: Optional[str] = None - self.connection_credentials: Optional["ConnectionCredentials"] = None + self.connection_credentials: Optional[ConnectionCredentials] = None + self._query_tagging: Optional[bool] = None @property def datasource_id(self) -> Optional[str]: @@ -36,6 +37,22 @@ def id(self) -> Optional[str]: def connection_type(self) -> Optional[str]: return self._connection_type + @property + def query_tagging(self) -> Optional[bool]: + return self._query_tagging + + @query_tagging.setter + @property_is_boolean + def query_tagging(self, value: Optional[bool]): + # if connection type = hyper, Snowflake, or Teradata, we can't change this value: it is always true + if self._connection_type in ["hyper", "snowflake", "teradata"]: + logger = logging.getLogger("tableauserverclient.models.connection_item") + logger.debug( + "Cannot update value: Query tagging is always enabled for {} connections".format(self._connection_type) + ) + return + self._query_tagging = value + def __repr__(self): return "".format( **self.__dict__ @@ -54,6 +71,7 @@ def from_response(cls, resp, ns) -> List["ConnectionItem"]: connection_item.server_address = connection_xml.get("serverAddress", None) connection_item.server_port = connection_xml.get("serverPort", None) connection_item.username = connection_xml.get("userName", None) + connection_item._query_tagging = string_to_bool(connection_xml.get("queryTaggingEnabled", None)) datasource_elem = connection_xml.find(".//t:datasource", namespaces=ns) if datasource_elem is not None: connection_item._datasource_id = datasource_elem.get("id", None) @@ -94,4 +112,4 @@ def from_xml_element(cls, parsed_response, ns) -> List["ConnectionItem"]: # Used to convert string represented boolean to a boolean type def string_to_bool(s: str) -> bool: - return s.lower() == "true" + return s is not None and s.lower() == "true" diff --git a/tableauserverclient/models/custom_view_item.py b/tableauserverclient/models/custom_view_item.py new file mode 100644 index 000000000..e0b47c738 --- /dev/null +++ b/tableauserverclient/models/custom_view_item.py @@ -0,0 +1,156 @@ +from datetime import datetime + +from defusedxml import ElementTree +from defusedxml.ElementTree import fromstring, tostring +from typing import Callable, List, Optional + +from .exceptions import UnpopulatedPropertyError +from .user_item import UserItem +from .view_item import ViewItem +from .workbook_item import WorkbookItem +from ..datetime_helpers import parse_datetime + + +class CustomViewItem(object): + def __init__(self, id: Optional[str] = None, name: Optional[str] = None) -> None: + self._content_url: Optional[str] = None # ? + self._created_at: Optional["datetime"] = None + self._id: Optional[str] = id + self._image: Optional[Callable[[], bytes]] = None + self._name: Optional[str] = name + self._shared: Optional[bool] = False + self._updated_at: Optional["datetime"] = None + + self._owner: Optional[UserItem] = None + self._view: Optional[ViewItem] = None + self._workbook: Optional[WorkbookItem] = None + + def __repr__(self: "CustomViewItem"): + view_info = "" + if self._view: + view_info = " view='{}'".format(self._view.name or self._view.id or "unknown") + wb_info = "" + if self._workbook: + wb_info = " workbook='{}'".format(self._workbook.name or self._workbook.id or "unknown") + owner_info = "" + if self._owner: + owner_info = " owner='{}'".format(self._owner.name or self._owner.id or "unknown") + return "".format(self.id, self.name, view_info, wb_info, owner_info) + + def _set_image(self, image): + self._image = image + + @property + def content_url(self) -> Optional[str]: + return self._content_url + + @property + def created_at(self) -> Optional["datetime"]: + return self._created_at + + @property + def id(self) -> Optional[str]: + return self._id + + @property + def image(self) -> bytes: + if self._image is None: + error = "View item must be populated with its png image first." + raise UnpopulatedPropertyError(error) + return self._image() + + @property + def name(self) -> Optional[str]: + return self._name + + @name.setter + def name(self, value: str): + self._name = value + + @property + def shared(self) -> Optional[bool]: + return self._shared + + @shared.setter + def shared(self, value: bool): + self._shared = value + + @property + def updated_at(self) -> Optional["datetime"]: + return self._updated_at + + @property + def owner(self) -> Optional[UserItem]: + return self._owner + + @owner.setter + def owner(self, value: UserItem): + self._owner = value + + @property + def workbook(self) -> Optional[WorkbookItem]: + return self._workbook + + @property + def view(self) -> Optional[ViewItem]: + return self._view + + @classmethod + def from_response(cls, resp, ns, workbook_id="") -> Optional["CustomViewItem"]: + item = cls.list_from_response(resp, ns, workbook_id) + if not item or len(item) == 0: + return None + else: + return item[0] + + @classmethod + def list_from_response(cls, resp, ns, workbook_id="") -> List["CustomViewItem"]: + return cls.from_xml_element(fromstring(resp), ns, workbook_id) + + """ + + + + + + """ + + @classmethod + def from_xml_element(cls, parsed_response, ns, workbook_id="") -> List["CustomViewItem"]: + all_view_items = list() + all_view_xml = parsed_response.findall(".//t:customView", namespaces=ns) + for custom_view_xml in all_view_xml: + cv_item = cls() + view_elem: ElementTree = custom_view_xml.find(".//t:view", namespaces=ns) + workbook_elem: str = custom_view_xml.find(".//t:workbook", namespaces=ns) + owner_elem: str = custom_view_xml.find(".//t:owner", namespaces=ns) + cv_item._created_at = parse_datetime(custom_view_xml.get("createdAt", None)) + cv_item._updated_at = parse_datetime(custom_view_xml.get("updatedAt", None)) + 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) + + if owner_elem is not None: + parsed_owners = UserItem.from_response_as_owner(tostring(custom_view_xml), ns) + if parsed_owners and len(parsed_owners) > 0: + cv_item._owner = parsed_owners[0] + + if view_elem is not None: + parsed_views = ViewItem.from_response(tostring(custom_view_xml), ns) + if parsed_views and len(parsed_views) > 0: + cv_item._view = parsed_views[0] + + if workbook_id: + cv_item._workbook = WorkbookItem(workbook_id) + elif workbook_elem is not None: + parsed_workbooks = WorkbookItem.from_response(tostring(custom_view_xml), ns) + if parsed_workbooks and len(parsed_workbooks) > 0: + cv_item._workbook = parsed_workbooks[0] + + all_view_items.append(cv_item) + return all_view_items diff --git a/tableauserverclient/models/data_alert_item.py b/tableauserverclient/models/data_alert_item.py index 3882d14eb..65be233e3 100644 --- a/tableauserverclient/models/data_alert_item.py +++ b/tableauserverclient/models/data_alert_item.py @@ -1,4 +1,5 @@ -from typing import List, Optional, TYPE_CHECKING +from datetime import datetime +from typing import List, Optional from defusedxml.ElementTree import fromstring @@ -8,15 +9,6 @@ property_is_boolean, ) -if TYPE_CHECKING: - from datetime import datetime - - -from typing import List, Optional, TYPE_CHECKING - -if TYPE_CHECKING: - from datetime import datetime - class DataAlertItem(object): class Frequency: @@ -30,8 +22,8 @@ def __init__(self): self._id: Optional[str] = None self._subject: Optional[str] = None self._creatorId: Optional[str] = None - self._createdAt: Optional["datetime"] = None - self._updatedAt: Optional["datetime"] = None + self._createdAt: Optional[datetime] = None + self._updatedAt: Optional[datetime] = None self._frequency: Optional[str] = None self._public: Optional[bool] = None self._owner_id: Optional[str] = None @@ -90,11 +82,11 @@ def recipients(self) -> List[str]: return self._recipients or list() @property - def createdAt(self) -> Optional["datetime"]: + def createdAt(self) -> Optional[datetime]: return self._createdAt @property - def updatedAt(self) -> Optional["datetime"]: + def updatedAt(self) -> Optional[datetime]: return self._updatedAt @property diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 4a7a74c4b..b5568a778 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -1,31 +1,21 @@ import copy +import datetime import xml.etree.ElementTree as ET -from typing import Dict, List, Optional, Set, Tuple, TYPE_CHECKING +from typing import Dict, List, Optional, Set, Tuple 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 ( property_not_nullable, property_is_boolean, property_is_enum, ) +from .revision_item import RevisionItem from .tag_item import TagItem -from ..datetime_helpers import parse_datetime - -if TYPE_CHECKING: - from .permissions_item import PermissionsRule - from .connection_item import ConnectionItem - from .revision_item import RevisionItem - import datetime - -from typing import Dict, List, Optional, Set, Tuple, TYPE_CHECKING, Union - -if TYPE_CHECKING: - from .permissions_item import PermissionsRule - from .connection_item import ConnectionItem - from .revision_item import RevisionItem - import datetime class DatasourceItem(object): @@ -34,6 +24,14 @@ class AskDataEnablement: Disabled = "Disabled" SiteDefault = "SiteDefault" + def __repr__(self): + return "".format( + self._id, + self.name, + self.description or "No Description", + self.project_id, + ) + def __init__(self, project_id: str, name: Optional[str] = None) -> None: self._ask_data_enablement = None self._certified = None @@ -64,23 +62,23 @@ def __init__(self, project_id: str, name: Optional[str] = None) -> None: return None @property - def ask_data_enablement(self) -> Optional["DatasourceItem.AskDataEnablement"]: + def ask_data_enablement(self) -> Optional[AskDataEnablement]: return self._ask_data_enablement @ask_data_enablement.setter @property_is_enum(AskDataEnablement) - def ask_data_enablement(self, value: Optional["DatasourceItem.AskDataEnablement"]): + def ask_data_enablement(self, value: Optional[AskDataEnablement]): self._ask_data_enablement = value @property - def connections(self) -> Optional[List["ConnectionItem"]]: + def connections(self) -> Optional[List[ConnectionItem]]: if self._connections is None: error = "Datasource item must be populated with connections first." raise UnpopulatedPropertyError(error) return self._connections() @property - def permissions(self) -> Optional[List["PermissionsRule"]]: + def permissions(self) -> Optional[List[PermissionsRule]]: if self._permissions is None: error = "Project item must be populated with permissions first." raise UnpopulatedPropertyError(error) @@ -91,7 +89,7 @@ def content_url(self) -> Optional[str]: return self._content_url @property - def created_at(self) -> Optional["datetime.datetime"]: + def created_at(self) -> Optional[datetime.datetime]: return self._created_at @property @@ -162,7 +160,7 @@ def description(self, value: str): self._description = value @property - def updated_at(self) -> Optional["datetime.datetime"]: + def updated_at(self) -> Optional[datetime.datetime]: return self._updated_at @property @@ -179,7 +177,7 @@ def webpage_url(self) -> Optional[str]: return self._webpage_url @property - def revisions(self) -> List["RevisionItem"]: + def revisions(self) -> List[RevisionItem]: if self._revisions is None: error = "Datasource item must be populated with revisions first." raise UnpopulatedPropertyError(error) diff --git a/tableauserverclient/models/dqw_item.py b/tableauserverclient/models/dqw_item.py index 2baecee09..ada041481 100644 --- a/tableauserverclient/models/dqw_item.py +++ b/tableauserverclient/models/dqw_item.py @@ -1,6 +1,6 @@ from defusedxml.ElementTree import fromstring -from ..datetime_helpers import parse_datetime +from tableauserverclient.datetime_helpers import parse_datetime class DQWItem(object): diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index 18f0ecae2..f48910602 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -1,43 +1,41 @@ import copy +import datetime import xml.etree.ElementTree as ET -from typing import List, Optional, TYPE_CHECKING, Set +from typing import List, Optional, Set from defusedxml.ElementTree import fromstring +from tableauserverclient.datetime_helpers import parse_datetime +from .connection_item import ConnectionItem +from .dqw_item import DQWItem from .exceptions import UnpopulatedPropertyError +from .permissions_item import Permission from .property_decorators import property_not_nullable from .tag_item import TagItem -from ..datetime_helpers import parse_datetime - -if TYPE_CHECKING: - import datetime - -from typing import List, Optional, TYPE_CHECKING, Set - -if TYPE_CHECKING: - import datetime - from .connection_item import ConnectionItem - from .permissions_item import Permission - from .dqw_item import DQWItem class FlowItem(object): + def __repr__(self): + return " None: self._webpage_url: Optional[str] = None - self._created_at: Optional["datetime.datetime"] = None + self._created_at: Optional[datetime.datetime] = None self._id: Optional[str] = None self._initial_tags: Set[str] = set() self._project_name: Optional[str] = None - self._updated_at: Optional["datetime.datetime"] = None + self._updated_at: Optional[datetime.datetime] = None self.name: Optional[str] = name self.owner_id: Optional[str] = None self.project_id: str = project_id self.tags: Set[str] = set() self.description: Optional[str] = None - self._connections = None - self._permissions = None - self._data_quality_warnings = None + self._connections: Optional[ConnectionItem] = None + self._permissions: Optional[Permission] = None + self._data_quality_warnings: Optional[DQWItem] = None @property def connections(self): @@ -58,7 +56,7 @@ def webpage_url(self) -> Optional[str]: return self._webpage_url @property - def created_at(self) -> Optional["datetime.datetime"]: + def created_at(self) -> Optional[datetime.datetime]: return self._created_at @property @@ -94,7 +92,7 @@ def project_name(self) -> Optional[str]: return self._project_name @property - def updated_at(self) -> Optional["datetime.datetime"]: + def updated_at(self) -> Optional[datetime.datetime]: return self._updated_at def _set_connections(self, connections): diff --git a/tableauserverclient/models/flow_run_item.py b/tableauserverclient/models/flow_run_item.py index ce859a65b..12281f4f8 100644 --- a/tableauserverclient/models/flow_run_item.py +++ b/tableauserverclient/models/flow_run_item.py @@ -1,17 +1,10 @@ import itertools -from typing import Dict, List, Optional, Type, TYPE_CHECKING +from datetime import datetime +from typing import Dict, List, Optional, Type from defusedxml.ElementTree import fromstring -from ..datetime_helpers import parse_datetime - -if TYPE_CHECKING: - from datetime import datetime - -from typing import Dict, List, Optional, Type, TYPE_CHECKING - -if TYPE_CHECKING: - from datetime import datetime +from tableauserverclient.datetime_helpers import parse_datetime class FlowRunItem(object): @@ -19,8 +12,8 @@ def __init__(self) -> None: self._id: str = "" self._flow_id: Optional[str] = None self._status: Optional[str] = None - self._started_at: Optional["datetime"] = None - self._completed_at: Optional["datetime"] = None + self._started_at: Optional[datetime] = None + self._completed_at: Optional[datetime] = None self._progress: Optional[str] = None self._background_job_id: Optional[str] = None @@ -37,11 +30,11 @@ def status(self) -> Optional[str]: return self._status @property - def started_at(self) -> Optional["datetime"]: + def started_at(self) -> Optional[datetime]: return self._started_at @property - def completed_at(self) -> Optional["datetime"]: + def completed_at(self) -> Optional[datetime]: return self._completed_at @property diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index a9cb2dcce..96c3ae675 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -8,7 +8,7 @@ from .user_item import UserItem if TYPE_CHECKING: - from ..server import Pager + from tableauserverclient.server import Pager class GroupItem(object): diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index a7490e705..5a2636246 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -1,12 +1,10 @@ -from typing import List, Optional, TYPE_CHECKING +import datetime +from typing import List, Optional from defusedxml.ElementTree import fromstring +from tableauserverclient.datetime_helpers import parse_datetime from .flow_run_item import FlowRunItem -from ..datetime_helpers import parse_datetime - -if TYPE_CHECKING: - import datetime class JobItem(object): @@ -25,16 +23,16 @@ def __init__( id_: str, job_type: str, progress: str, - created_at: "datetime.datetime", - started_at: Optional["datetime.datetime"] = None, - completed_at: Optional["datetime.datetime"] = None, + created_at: datetime.datetime, + started_at: Optional[datetime.datetime] = None, + completed_at: Optional[datetime.datetime] = None, finish_code: int = 0, notes: Optional[List[str]] = None, mode: Optional[str] = None, workbook_id: Optional[str] = None, datasource_id: Optional[str] = None, flow_run: Optional[FlowRunItem] = None, - updated_at: Optional["datetime.datetime"] = None, + updated_at: Optional[datetime.datetime] = None, ): self._id = id_ self._type = job_type @@ -63,15 +61,15 @@ def progress(self) -> str: return self._progress @property - def created_at(self) -> "datetime.datetime": + def created_at(self) -> datetime.datetime: return self._created_at @property - def started_at(self) -> Optional["datetime.datetime"]: + def started_at(self) -> Optional[datetime.datetime]: return self._started_at @property - def completed_at(self) -> Optional["datetime.datetime"]: + def completed_at(self) -> Optional[datetime.datetime]: return self._completed_at @property @@ -116,7 +114,7 @@ def flow_run(self, value): self._flow_run = value @property - def updated_at(self) -> Optional["datetime.datetime"]: + def updated_at(self) -> Optional[datetime.datetime]: return self._updated_at def __repr__(self): @@ -185,14 +183,14 @@ class Status: def __init__( self, id_: str, - created_at: "datetime.datetime", + created_at: datetime.datetime, priority: int, job_type: str, status: str, title: Optional[str] = None, subtitle: Optional[str] = None, - started_at: Optional["datetime.datetime"] = None, - ended_at: Optional["datetime.datetime"] = None, + started_at: Optional[datetime.datetime] = None, + ended_at: Optional[datetime.datetime] = None, ): self._id = id_ self._type = job_type @@ -223,15 +221,15 @@ def type(self) -> str: return self._type @property - def created_at(self) -> "datetime.datetime": + def created_at(self) -> datetime.datetime: return self._created_at @property - def started_at(self) -> Optional["datetime.datetime"]: + def started_at(self) -> Optional[datetime.datetime]: return self._started_at @property - def ended_at(self) -> Optional["datetime.datetime"]: + def ended_at(self) -> Optional[datetime.datetime]: return self._ended_at @property diff --git a/tableauserverclient/models/metric_item.py b/tableauserverclient/models/metric_item.py index a54d1e30e..4adc73fa8 100644 --- a/tableauserverclient/models/metric_item.py +++ b/tableauserverclient/models/metric_item.py @@ -1,11 +1,10 @@ import xml.etree.ElementTree as ET -from ..datetime_helpers import parse_datetime +from datetime import datetime +from typing import List, Optional, Set + +from tableauserverclient.datetime_helpers import parse_datetime from .property_decorators import property_is_boolean, property_is_datetime from .tag_item import TagItem -from typing import List, Optional, TYPE_CHECKING, Set - -if TYPE_CHECKING: - from datetime import datetime class MetricItem(object): @@ -14,8 +13,8 @@ def __init__(self, name: Optional[str] = None): self._name: Optional[str] = name self._description: Optional[str] = None self._webpage_url: Optional[str] = None - self._created_at: Optional["datetime"] = None - self._updated_at: Optional["datetime"] = None + self._created_at: Optional[datetime] = None + self._updated_at: Optional[datetime] = None self._suspended: Optional[bool] = None self._project_id: Optional[str] = None self._project_name: Optional[str] = None @@ -53,7 +52,7 @@ def webpage_url(self) -> Optional[str]: return self._webpage_url @property - def created_at(self) -> Optional["datetime"]: + def created_at(self) -> Optional[datetime]: return self._created_at @created_at.setter @@ -62,7 +61,7 @@ def created_at(self, value: "datetime") -> None: self._created_at = value @property - def updated_at(self) -> Optional["datetime"]: + def updated_at(self) -> Optional[datetime]: return self._updated_at @updated_at.setter diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 74b167e9d..3bdc63092 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -1,18 +1,16 @@ import logging import xml.etree.ElementTree as ET +from typing import Dict, List, Optional from defusedxml.ElementTree import fromstring + from .exceptions import UnknownGranteeTypeError, UnpopulatedPropertyError from .group_item import GroupItem +from .reference_item import ResourceReference from .user_item import UserItem logger = logging.getLogger("tableau.models.permissions_item") -from typing import Dict, List, Optional, TYPE_CHECKING - -if TYPE_CHECKING: - from .reference_item import ResourceReference - class Permission: class Mode: @@ -43,7 +41,7 @@ class Capability: class PermissionsRule(object): - def __init__(self, grantee: "ResourceReference", capabilities: Dict[str, str]) -> None: + def __init__(self, grantee: ResourceReference, capabilities: Dict[str, str]) -> None: self.grantee = grantee self.capabilities = capabilities @@ -80,7 +78,7 @@ def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: return rules @staticmethod - def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[Dict[str, str]]) -> "ResourceReference": + def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[Dict[str, str]]) -> ResourceReference: """Use Xpath magic and some string splitting to get the right object type from the xml""" # Get the first element in the tree with an 'id' attribute diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index a8430bfd0..21358431c 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -1,13 +1,12 @@ import logging import xml.etree.ElementTree as ET +from typing import List, Optional from defusedxml.ElementTree import fromstring from .exceptions import UnpopulatedPropertyError from .property_decorators import property_is_enum, property_not_empty -from typing import List, Optional - class ProjectItem(object): class ContentPermissions: @@ -15,6 +14,11 @@ class ContentPermissions: ManagedByOwner: str = "ManagedByOwner" LockedToProjectWithoutNested: str = "LockedToProjectWithoutNested" + def __repr__(self): + return "".format( + self._id, self.name, self.parent_id or "None (Top level)", self.content_permissions or "Not Set" + ) + def __init__( self, name: str, diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index af8883290..7c801a4b5 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -2,7 +2,7 @@ import re from functools import wraps -from ..datetime_helpers import parse_datetime +from tableauserverclient.datetime_helpers import parse_datetime def property_is_enum(enum_type): diff --git a/tableauserverclient/models/revision_item.py b/tableauserverclient/models/revision_item.py index 600d73168..a0e6a1bd5 100644 --- a/tableauserverclient/models/revision_item.py +++ b/tableauserverclient/models/revision_item.py @@ -1,11 +1,9 @@ -from typing import List, Optional, TYPE_CHECKING +from datetime import datetime +from typing import List, Optional from defusedxml.ElementTree import fromstring -from ..datetime_helpers import parse_datetime - -if TYPE_CHECKING: - from datetime import datetime +from tableauserverclient.datetime_helpers import parse_datetime class RevisionItem(object): @@ -15,7 +13,7 @@ def __init__(self): self._revision_number: Optional[str] = None self._current: Optional[bool] = None self._deleted: Optional[bool] = None - self._created_at: Optional["datetime"] = None + self._created_at: Optional[datetime] = None self._user_id: Optional[str] = None self._user_name: Optional[str] = None @@ -40,7 +38,7 @@ def deleted(self) -> Optional[bool]: return self._deleted @property - def created_at(self) -> Optional["datetime"]: + def created_at(self) -> Optional[datetime]: return self._created_at @property diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index 828034d23..54e4badbe 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -4,6 +4,7 @@ from defusedxml.ElementTree import fromstring +from tableauserverclient.datetime_helpers import parse_datetime from .interval_item import ( IntervalItem, HourlyInterval, @@ -16,7 +17,6 @@ property_not_nullable, property_is_int, ) -from ..datetime_helpers import parse_datetime Interval = Union[HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval] diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py index 350ae3a0d..5f9395880 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -1,3 +1,4 @@ +import logging import warnings import xml @@ -35,11 +36,18 @@ def rest_api_version(self): @classmethod def from_response(cls, resp, ns): + logger = logging.getLogger("TSC.ServerInfo") try: parsed_response = fromstring(resp) except xml.etree.ElementTree.ParseError as error: - warnings.warn("Unexpected response for ServerInfo: {}".format(resp)) + logger.info("Unexpected response for ServerInfo: {}".format(resp)) + logger.info(error) return cls("Unknown", "Unknown", "Unknown") + except Exception as error: + logger.info("Unexpected response for ServerInfo: {}".format(resp)) + logger.info(error) + return cls("Unknown", "Unknown", "Unknown") + product_version_tag = parsed_response.find(".//t:productVersion", namespaces=ns) rest_api_version_tag = parsed_response.find(".//t:restApiVersion", namespaces=ns) diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index e6bc3af24..813e812af 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -2,6 +2,7 @@ import xml.etree.ElementTree as ET from defusedxml.ElementTree import fromstring + from .property_decorators import ( property_is_enum, property_is_boolean, diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 24ba1d682..db21e4aa2 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -53,6 +53,8 @@ def site(self, value): class PersonalAccessTokenAuth(Credentials): def __init__(self, token_name, personal_access_token, site_id=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) self.token_name = token_name self.personal_access_token = personal_access_token diff --git a/tableauserverclient/models/tableau_types.py b/tableauserverclient/models/tableau_types.py index 6ed77318f..9649c7ed9 100644 --- a/tableauserverclient/models/tableau_types.py +++ b/tableauserverclient/models/tableau_types.py @@ -1,13 +1,11 @@ -from tableauserverclient.models.database_item import DatabaseItem -from tableauserverclient.models.datasource_item import DatasourceItem -from tableauserverclient.models.flow_item import FlowItem -from tableauserverclient.models.project_item import ProjectItem -from tableauserverclient.models.table_item import TableItem -from tableauserverclient.models.view_item import ViewItem -from tableauserverclient.models.workbook_item import WorkbookItem - from typing import Union +from .datasource_item import DatasourceItem +from .flow_item import FlowItem +from .project_item import ProjectItem +from .view_item import ViewItem +from .workbook_item import WorkbookItem + class Resource: Database = "database" diff --git a/tableauserverclient/models/tag_item.py b/tableauserverclient/models/tag_item.py index f7568ae45..afa0a0762 100644 --- a/tableauserverclient/models/tag_item.py +++ b/tableauserverclient/models/tag_item.py @@ -1,5 +1,6 @@ -from typing import Set import xml.etree.ElementTree as ET +from typing import Set + from defusedxml.ElementTree import fromstring diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index 32299a853..159869b07 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -1,8 +1,8 @@ from defusedxml.ElementTree import fromstring +from tableauserverclient.datetime_helpers import parse_datetime from .schedule_item import ScheduleItem from .target import Target -from ..datetime_helpers import parse_datetime class TaskItem(object): diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index c19fd4f97..5e3d18fa6 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -1,23 +1,21 @@ import io -import logging import xml.etree.ElementTree as ET from datetime import datetime from enum import IntEnum +from typing import Dict, List, Optional, TYPE_CHECKING, Tuple from defusedxml.ElementTree import fromstring +from tableauserverclient.datetime_helpers import parse_datetime from .exceptions import UnpopulatedPropertyError from .property_decorators import ( property_is_enum, property_not_empty, ) from .reference_item import ResourceReference -from ..datetime_helpers import parse_datetime - -from typing import Dict, List, Optional, TYPE_CHECKING, Tuple if TYPE_CHECKING: - from ..server.pager import Pager + from tableauserverclient.server import Pager class UserItem(object): @@ -93,6 +91,10 @@ def external_auth_user_id(self) -> Optional[str]: def id(self) -> Optional[str]: return self._id + @id.setter + def id(self, value: str) -> None: + self._id = value + @property def last_login(self) -> Optional[datetime]: return self._last_login @@ -102,7 +104,6 @@ def name(self) -> Optional[str]: return self._name @name.setter - @property_not_empty def name(self, value: str): self._name = value @@ -206,9 +207,19 @@ def _set_values( @classmethod def from_response(cls, resp, ns) -> List["UserItem"]: + element_name = ".//t:user" + return cls._parse_xml(element_name, resp, ns) + + @classmethod + def from_response_as_owner(cls, resp, ns) -> List["UserItem"]: + element_name = ".//t:owner" + return cls._parse_xml(element_name, resp, ns) + + @classmethod + def _parse_xml(cls, element_name, resp, ns): all_user_items = [] parsed_response = fromstring(resp) - all_user_xml = parsed_response.findall(".//t:user", namespaces=ns) + all_user_xml = parsed_response.findall(element_name, namespaces=ns) for user_xml in all_user_xml: ( id, diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index 01635349b..51cceaa9f 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -1,21 +1,19 @@ import copy -from typing import Callable, Generator, Iterator, List, Optional, Set, TYPE_CHECKING +from datetime import datetime +from typing import Callable, Iterator, List, Optional, Set from defusedxml.ElementTree import fromstring +from tableauserverclient.datetime_helpers import parse_datetime from .exceptions import UnpopulatedPropertyError +from .permissions_item import PermissionsRule from .tag_item import TagItem -from ..datetime_helpers import parse_datetime - -if TYPE_CHECKING: - from datetime import datetime - from .permissions_item import PermissionsRule class ViewItem(object): def __init__(self) -> None: self._content_url: Optional[str] = None - self._created_at: Optional["datetime"] = None + self._created_at: Optional[datetime] = None self._id: Optional[str] = None self._image: Optional[Callable[[], bytes]] = None self._initial_tags: Set[str] = set() @@ -28,11 +26,16 @@ def __init__(self) -> None: self._excel: Optional[Callable[[], Iterator[bytes]]] = None self._total_views: Optional[int] = None self._sheet_type: Optional[str] = None - self._updated_at: Optional["datetime"] = None + self._updated_at: Optional[datetime] = None self._workbook_id: Optional[str] = None - self._permissions: Optional[Callable[[], List["PermissionsRule"]]] = None + self._permissions: Optional[Callable[[], List[PermissionsRule]]] = None self.tags: Set[str] = set() + def __repr__(self): + return "".format( + self._id, self.name, self.content_url, self.project_id + ) + def _set_preview_image(self, preview_image): self._preview_image = preview_image @@ -53,7 +56,7 @@ def content_url(self) -> Optional[str]: return self._content_url @property - def created_at(self) -> Optional["datetime"]: + def created_at(self) -> Optional[datetime]: return self._created_at @property @@ -119,7 +122,7 @@ def total_views(self): return self._total_views @property - def updated_at(self) -> Optional["datetime"]: + def updated_at(self) -> Optional[datetime]: return self._updated_at @property @@ -127,13 +130,13 @@ def workbook_id(self) -> Optional[str]: return self._workbook_id @property - def permissions(self) -> List["PermissionsRule"]: + def permissions(self) -> List[PermissionsRule]: if self._permissions is None: error = "View item must be populated with permissions first." raise UnpopulatedPropertyError(error) return self._permissions() - def _set_permissions(self, permissions: Callable[[], List["PermissionsRule"]]) -> None: + def _set_permissions(self, permissions: Callable[[], List[PermissionsRule]]) -> None: self._permissions = permissions @classmethod diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 6d9a21b6b..debbf30b5 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -1,35 +1,22 @@ import copy +import datetime import uuid import xml.etree.ElementTree as ET -from typing import Callable, Dict, List, Optional, Set, TYPE_CHECKING +from typing import Callable, Dict, List, Optional, Set 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 ( - property_not_nullable, property_is_boolean, property_is_data_acceleration_config, ) +from .revision_item import RevisionItem from .tag_item import TagItem from .view_item import ViewItem -from ..datetime_helpers import parse_datetime - - -if TYPE_CHECKING: - from .connection_item import ConnectionItem - from .permissions_item import PermissionsRule - import datetime - from .revision_item import RevisionItem - -from typing import Dict, List, Optional, Set, TYPE_CHECKING, Union - -if TYPE_CHECKING: - from .connection_item import ConnectionItem - from .permissions_item import PermissionsRule - import datetime - from .revision_item import RevisionItem class WorkbookItem(object): @@ -65,15 +52,20 @@ def __init__(self, project_id: str, name: Optional[str] = None, show_tabs: bool return None + def __repr__(self): + return "".format( + self._id, self.name, self.content_url, self.project_id + ) + @property - def connections(self) -> List["ConnectionItem"]: + def connections(self) -> List[ConnectionItem]: if self._connections is None: error = "Workbook item must be populated with connections first." raise UnpopulatedPropertyError(error) return self._connections() @property - def permissions(self) -> List["PermissionsRule"]: + def permissions(self) -> List[PermissionsRule]: if self._permissions is None: error = "Workbook item must be populated with permissions first." raise UnpopulatedPropertyError(error) @@ -88,7 +80,7 @@ def webpage_url(self) -> Optional[str]: return self._webpage_url @property - def created_at(self) -> Optional["datetime.datetime"]: + def created_at(self) -> Optional[datetime.datetime]: return self._created_at @property @@ -146,7 +138,7 @@ def size(self): return self._size @property - def updated_at(self) -> Optional["datetime.datetime"]: + def updated_at(self) -> Optional[datetime.datetime]: return self._updated_at @property @@ -176,7 +168,7 @@ def data_acceleration_config(self, value): self._data_acceleration_config = value @property - def revisions(self) -> List["RevisionItem"]: + def revisions(self) -> List[RevisionItem]: if self._revisions is None: error = "Workbook item must be populated with revisions first." raise UnpopulatedPropertyError(error) diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index 84d118a2e..bcea2604e 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -10,56 +10,8 @@ from .filter import Filter from .sort import Sort -from ..models import ( - BackgroundJobItem, - ColumnItem, - ConnectionItem, - DQWItem, - DataAlertItem, - DatabaseItem, - DatasourceItem, - FlowItem, - FlowRunItem, - GroupItem, - JobItem, - PaginationItem, - Permission, - PermissionsRule, - ProjectItem, - RevisionItem, - ScheduleItem, - SiteItem, - SubscriptionItem, - TableItem, - TableauAuth, - TaskItem, - UserItem, - ViewItem, - WebhookItem, - WorkbookItem, - TableauItem, - Resource, - plural_type, -) -from .endpoint import ( - Auth, - DataAlerts, - Datasources, - Endpoint, - Groups, - Projects, - Schedules, - Sites, - Tables, - Users, - Views, - Workbooks, - Subscriptions, - ServerResponseError, - MissingRequiredFieldError, - Flows, - Favorites, -) +from ..models import * +from .endpoint import * from .server import Server from .pager import Pager from .exceptions import NotSignedInError diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index e14bb8cff..e8e1bc0f9 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -1,4 +1,5 @@ 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 diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py new file mode 100644 index 000000000..778cafecc --- /dev/null +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -0,0 +1,104 @@ +import logging +from typing import List, Optional, Tuple + +from .endpoint import QuerysetEndpoint, api +from .exceptions import MissingRequiredFieldError +from tableauserverclient.models import CustomViewItem, PaginationItem +from tableauserverclient.server import RequestFactory, RequestOptions, ImageRequestOptions + +logger = logging.getLogger("tableau.endpoint.custom_views") + +""" +Get a list of custom views on a site +get the details of a custom view +download an image of a custom view. +Delete a custom view +update the name or owner of a custom view. +""" + + +class CustomViews(QuerysetEndpoint): + def __init__(self, parent_srv): + super(CustomViews, self).__init__(parent_srv) + + @property + def baseurl(self) -> str: + return "{0}/sites/{1}/customviews".format(self.parent_srv.baseurl, self.parent_srv.site_id) + + """ + If the request has no filter parameters: Administrators will see all custom views. + Other users will see only custom views that they own. + If the filter parameters include ownerId: Users will see only custom views that they own. + If the filter parameters include viewId and/or workbookId, and don't include ownerId: + Users will see those custom views that they have Write and WebAuthoring permissions for. + If site user visibility is not set to Limited, the Users will see those custom views that are "public", + meaning the value of their shared attribute is true. + If site user visibility is set to Limited, ???? + """ + + @api(version="3.18") + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[CustomViewItem], PaginationItem]: + logger.info("Querying all custom views on site") + url = self.baseurl + server_response = self.get_request(url, req_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + all_view_items = CustomViewItem.list_from_response(server_response.content, self.parent_srv.namespace) + return all_view_items, pagination_item + + @api(version="3.18") + def get_by_id(self, view_id: str) -> Optional[CustomViewItem]: + if not view_id: + error = "Custom view item missing ID." + raise MissingRequiredFieldError(error) + logger.info("Querying custom view (ID: {0})".format(view_id)) + url = "{0}/{1}".format(self.baseurl, view_id) + server_response = self.get_request(url) + return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace) + + @api(version="3.18") + def populate_image(self, view_item: CustomViewItem, req_options: Optional["ImageRequestOptions"] = None) -> None: + if not view_item.id: + error = "Custom View item missing ID." + raise MissingRequiredFieldError(error) + + def image_fetcher(): + return self._get_view_image(view_item, req_options) + + view_item._set_image(image_fetcher) + logger.info("Populated image for custom view (ID: {0})".format(view_item.id)) + + def _get_view_image(self, view_item: CustomViewItem, req_options: Optional["ImageRequestOptions"]) -> bytes: + url = "{0}/{1}/image".format(self.baseurl, view_item.id) + server_response = self.get_request(url, req_options) + image = server_response.content + return image + + """ + Not yet implemented: pdf or csv exports + """ + + @api(version="3.18") + def update(self, view_item: CustomViewItem) -> Optional[CustomViewItem]: + if not view_item.id: + error = "Custom view item missing ID." + raise MissingRequiredFieldError(error) + if not (view_item.owner or view_item.name or view_item.shared): + logger.debug("No changes to make") + return view_item + + # Update the custom view owner or name + url = "{0}/{1}".format(self.baseurl, view_item.id) + update_req = RequestFactory.CustomView.update_req(view_item) + server_response = self.put_request(url, update_req) + logger.info("Updated custom view (ID: {0})".format(view_item.id)) + return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace) + + # Delete 1 view by id + @api(version="3.19") + def delete(self, view_id: str) -> None: + if not view_id: + error = "Custom View ID undefined." + raise ValueError(error) + url = "{0}/{1}".format(self.baseurl, view_id) + self.delete_request(url) + logger.info("Deleted single custom view (ID: {0})".format(view_id)) diff --git a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py index f972c0d60..28e5495c5 100644 --- a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py +++ b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py @@ -3,7 +3,7 @@ from .default_permissions_endpoint import _DefaultPermissionsEndpoint from .endpoint import api, Endpoint from .permissions_endpoint import _PermissionsEndpoint -from ...models.data_acceleration_report_item import DataAccelerationReportItem +from tableauserverclient.models import DataAccelerationReportItem logger = logging.getLogger("tableau.endpoint.data_acceleration_report") diff --git a/tableauserverclient/server/endpoint/data_alert_endpoint.py b/tableauserverclient/server/endpoint/data_alert_endpoint.py index 8929f8c6a..5af4e0464 100644 --- a/tableauserverclient/server/endpoint/data_alert_endpoint.py +++ b/tableauserverclient/server/endpoint/data_alert_endpoint.py @@ -2,7 +2,8 @@ from .endpoint import api, Endpoint from .exceptions import MissingRequiredFieldError -from .. import RequestFactory, DataAlertItem, PaginationItem, UserItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import DataAlertItem, PaginationItem, UserItem logger = logging.getLogger("tableau.endpoint.dataAlerts") diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index aa9d73f18..2522ef53e 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -5,7 +5,8 @@ from .endpoint import api, Endpoint from .exceptions import MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint -from .. import RequestFactory, DatabaseItem, TableItem, PaginationItem, Resource +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import DatabaseItem, TableItem, PaginationItem, Resource logger = logging.getLogger("tableau.endpoint.databases") diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 97c39d1bb..0c5b8ba61 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -1,40 +1,49 @@ import cgi import copy -import io import json import logging +import io import os + from contextlib import closing from pathlib import Path -from typing import ( - List, - Mapping, - Optional, - Sequence, - Tuple, - TYPE_CHECKING, - Union, -) +from typing import List, Mapping, Optional, Sequence, Tuple, TYPE_CHECKING, Union + +if TYPE_CHECKING: + from tableauserverclient.server import Server + 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 .. import RequestFactory, DatasourceItem, PaginationItem, ConnectionItem, RequestOptions -from ..query import QuerySet -from ...filesys_helpers import ( + +from tableauserverclient.server import RequestFactory, RequestOptions +from tableauserverclient.filesys_helpers import ( to_filename, make_download_path, get_file_type, get_file_object_size, ) -from ...models import ConnectionCredentials, RevisionItem -from ...models.job_item import JobItem +from tableauserverclient.models import ( + ConnectionCredentials, + ConnectionItem, + DatasourceItem, + JobItem, + RevisionItem, + PaginationItem, +) +io_types = (io.BytesIO, io.BufferedReader) io_types_r = (io.BytesIO, io.BufferedReader) io_types_w = (io.BytesIO, io.BufferedWriter) +FilePath = Union[str, os.PathLike] +FileObject = Union[io.BufferedReader, io.BytesIO] +PathOrFile = Union[FilePath, FileObject] + # The maximum size of a file that can be published in a single request is 64MB FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB @@ -42,11 +51,6 @@ logger = logging.getLogger("tableau.endpoint.datasources") -if TYPE_CHECKING: - from ..server import Server - from ...models import PermissionsRule - from .schedules_endpoint import AddResponse - FilePath = Union[str, os.PathLike] FileObjectR = Union[io.BufferedReader, io.BytesIO] FileObjectW = Union[io.BufferedWriter, io.BytesIO] @@ -136,11 +140,20 @@ def update(self, datasource_item: DatasourceItem) -> DatasourceItem: if not datasource_item.id: error = "Datasource item missing ID. Datasource must be retrieved from server first." raise MissingRequiredFieldError(error) + # bug - before v3.15 you must always include the project id + if datasource_item.owner_id and not datasource_item.project_id: + if not self.parent_srv.check_at_least_version("3.15"): + error = ( + "Attempting to set new owner but datasource is missing Project ID." + "In versions before 3.15 the project id must be included to update the owner." + ) + raise MissingRequiredFieldError(error) self._resource_tagger.update_tags(self.baseurl, datasource_item) # Update the datasource itself url = "{0}/{1}".format(self.baseurl, datasource_item.id) + update_req = RequestFactory.Datasource.update_req(datasource_item) server_response = self.put_request(url, update_req) logger.info("Updated datasource item (ID: {0})".format(datasource_item.id)) diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py index 66fc23d49..b0d16efaf 100644 --- a/tableauserverclient/server/endpoint/default_permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py @@ -2,8 +2,8 @@ from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError -from .. import RequestFactory -from ...models import DatabaseItem, PermissionsRule, ProjectItem, plural_type, Resource +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import DatabaseItem, PermissionsRule, ProjectItem, plural_type, Resource from typing import TYPE_CHECKING, Callable, List, Optional, Sequence, Union if TYPE_CHECKING: diff --git a/tableauserverclient/server/endpoint/dqw_endpoint.py b/tableauserverclient/server/endpoint/dqw_endpoint.py index ff1637721..96cb7c5f9 100644 --- a/tableauserverclient/server/endpoint/dqw_endpoint.py +++ b/tableauserverclient/server/endpoint/dqw_endpoint.py @@ -2,7 +2,8 @@ from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError -from .. import RequestFactory, DQWItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import DQWItem logger = logging.getLogger(__name__) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index b1a42b20c..9c933c9dd 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -3,7 +3,7 @@ from packaging.version import Version from functools import wraps from xml.etree.ElementTree import ParseError -from typing import Any, Callable, Dict, Optional, TYPE_CHECKING, Mapping +from typing import Any, Callable, Dict, Optional, TYPE_CHECKING from .exceptions import ( ServerResponseError, @@ -11,8 +11,8 @@ NonXMLResponseError, EndpointUnavailableError, ) -from ..query import QuerySet -from ... import helpers, get_versions +from tableauserverclient.server.query import QuerySet +from tableauserverclient import helpers, get_versions if TYPE_CHECKING: from ..server import Server @@ -78,16 +78,16 @@ def _make_request( self.parent_srv.http_options, auth_token, content, content_type, parameters ) - logger.debug("request {}, url: {}".format(method, url)) + logger.debug("request method {}, url: {}".format(method.__name__, url)) if content: redacted = helpers.strings.redact_xml(content[:1000]) - logger.debug("request content: {}".format(redacted)) + # logger.debug("request content: {}".format(redacted)) server_response = method(url, **parameters) self._check_status(server_response, url) loggable_response = self.log_response_safely(server_response) - logger.debug("Server response from {0}:\n\t{1}".format(url, loggable_response)) + # logger.debug("Server response from {0}:\n\t{1}".format(url, loggable_response)) if content_type == "application/xml": self.parent_srv._namespace.detect(server_response.content) @@ -258,7 +258,7 @@ def all(self, *args, **kwargs): return queryset @api(version="2.0") - def filter(self, *_, **kwargs): + def filter(self, *_, **kwargs) -> QuerySet: if _: raise RuntimeError("Only keyword arguments accepted.") queryset = QuerySet(self).filter(**kwargs) diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index 19199c5a0..5105b3bf4 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -1,10 +1,8 @@ import logging from .endpoint import Endpoint, api -from .. import RequestFactory -from ...models import FavoriteItem - -logger = logging.getLogger("tableau.endpoint.favorites") +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import FavoriteItem from typing import Optional, TYPE_CHECKING @@ -12,6 +10,8 @@ from ...models import DatasourceItem, FlowItem, ProjectItem, UserItem, ViewItem, WorkbookItem from ..request_options import RequestOptions +logger = logging.getLogger("tableau.endpoint.favorites") + class Favorites(Endpoint): @property diff --git a/tableauserverclient/server/endpoint/fileuploads_endpoint.py b/tableauserverclient/server/endpoint/fileuploads_endpoint.py index 3df8ee4d5..9a8e9560d 100644 --- a/tableauserverclient/server/endpoint/fileuploads_endpoint.py +++ b/tableauserverclient/server/endpoint/fileuploads_endpoint.py @@ -1,8 +1,8 @@ import logging from .endpoint import Endpoint, api -from .. import RequestFactory -from ...models.fileupload_item import FileuploadItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import FileuploadItem # For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks CHUNK_SIZE = 1024 * 1024 * 5 # 5MB diff --git a/tableauserverclient/server/endpoint/flow_runs_endpoint.py b/tableauserverclient/server/endpoint/flow_runs_endpoint.py index 62f910dea..3bca93a7f 100644 --- a/tableauserverclient/server/endpoint/flow_runs_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_runs_endpoint.py @@ -3,8 +3,8 @@ from .endpoint import QuerysetEndpoint, api from .exceptions import FlowRunFailedException, FlowRunCancelledException -from .. import FlowRunItem, PaginationItem -from ...exponential_backoff import ExponentialBackoffTimer +from tableauserverclient.models import FlowRunItem, PaginationItem +from tableauserverclient.exponential_backoff import ExponentialBackoffTimer logger = logging.getLogger("tableau.endpoint.flowruns") diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 5b182111b..4d97110c4 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -8,18 +8,21 @@ from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union from .dqw_endpoint import _DataQualityWarningEndpoint -from .endpoint import Endpoint, QuerysetEndpoint, api +from .endpoint import QuerysetEndpoint, api from .exceptions import InternalServerError, MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint from .resource_tagger import _ResourceTagger -from .. import RequestFactory, FlowItem, PaginationItem, ConnectionItem -from ...filesys_helpers import ( +from tableauserverclient.models import FlowItem, PaginationItem, ConnectionItem, JobItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.filesys_helpers import ( to_filename, make_download_path, get_file_type, get_file_object_size, ) -from ...models.job_item import JobItem + +io_types_r = (io.BytesIO, io.BufferedReader) +io_types_w = (io.BytesIO, io.BufferedWriter) io_types_r = (io.BytesIO, io.BufferedReader) io_types_w = (io.BytesIO, io.BufferedWriter) diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index 289ccdb11..ba5b6649b 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -2,7 +2,8 @@ from .endpoint import QuerysetEndpoint, api from .exceptions import MissingRequiredFieldError -from .. import RequestFactory, GroupItem, UserItem, PaginationItem, JobItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import GroupItem, UserItem, PaginationItem, JobItem from ..pager import Pager logger = logging.getLogger("tableau.endpoint.groups") diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index 6b709efad..dd210d990 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -2,9 +2,9 @@ from .endpoint import QuerysetEndpoint, api from .exceptions import JobCancelledException, JobFailedException -from .. import JobItem, BackgroundJobItem, PaginationItem +from tableauserverclient.models import JobItem, BackgroundJobItem, PaginationItem from ..request_options import RequestOptionsBase -from ...exponential_backoff import ExponentialBackoffTimer +from tableauserverclient.exponential_backoff import ExponentialBackoffTimer logger = logging.getLogger("tableau.endpoint.jobs") diff --git a/tableauserverclient/server/endpoint/metrics_endpoint.py b/tableauserverclient/server/endpoint/metrics_endpoint.py index fba2632a4..8443726cd 100644 --- a/tableauserverclient/server/endpoint/metrics_endpoint.py +++ b/tableauserverclient/server/endpoint/metrics_endpoint.py @@ -3,11 +3,10 @@ from .permissions_endpoint import _PermissionsEndpoint from .dqw_endpoint import _DataQualityWarningEndpoint from .resource_tagger import _ResourceTagger -from .. import RequestFactory, PaginationItem -from ...models.metric_item import MetricItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import MetricItem, PaginationItem import logging -import copy from typing import List, Optional, TYPE_CHECKING, Tuple diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py index e3e9af2a6..e50e32945 100644 --- a/tableauserverclient/server/endpoint/permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/permissions_endpoint.py @@ -1,12 +1,12 @@ import logging -from .. import RequestFactory, PermissionsRule +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import TableauItem, PermissionsRule from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError -from ...models import TableauItem -from typing import Optional, Callable, TYPE_CHECKING, List, Union +from typing import Callable, TYPE_CHECKING, List, Optional, Union logger = logging.getLogger(__name__) diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 7ccdcd775..440940606 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -4,7 +4,8 @@ from .endpoint import QuerysetEndpoint, api, XML_CONTENT_TYPE from .exceptions import MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint -from .. import RequestFactory, RequestOptions, ProjectItem, PaginationItem, Resource +from tableauserverclient.server import RequestFactory, RequestOptions +from tableauserverclient.models import ProjectItem, PaginationItem, Resource from typing import List, Optional, Tuple, TYPE_CHECKING diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index d5bc4dccb..18c38798e 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -4,8 +4,8 @@ from .endpoint import Endpoint from .exceptions import EndpointUnavailableError, ServerResponseError -from .. import RequestFactory -from ...models.tag_item import TagItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import TagItem logger = logging.getLogger("tableau.endpoint.resource_tagger") diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index 3010eeb3a..7cca1f5d5 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -6,7 +6,8 @@ from .endpoint import Endpoint, api, parameter_added_in from .exceptions import MissingRequiredFieldError -from .. import RequestFactory, PaginationItem, ScheduleItem, TaskItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import PaginationItem, ScheduleItem, TaskItem logger = logging.getLogger("tableau.endpoint.schedules") AddResponse = namedtuple("AddResponse", ("result", "error", "warnings", "task_created")) diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index 943aabee6..b396a1f87 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -6,7 +6,7 @@ ServerInfoEndpointNotFoundError, EndpointUnavailableError, ) -from ...models import ServerInfoItem +from tableauserverclient.models import ServerInfoItem logger = logging.getLogger("tableau.endpoint.server_info") @@ -41,5 +41,9 @@ def get(self): raise EndpointUnavailableError(e) raise e - self._info = ServerInfoItem.from_response(server_response.content, self.parent_srv.namespace) + try: + self._info = ServerInfoItem.from_response(server_response.content, self.parent_srv.namespace) + except Exception as e: + logging.getLogger(self.__class__.__name__).debug(e) + logging.getLogger(self.__class__.__name__).debug(server_response.content) return self._info diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index 67d7db209..a4c765484 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -3,7 +3,8 @@ from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError -from .. import RequestFactory, SiteItem, PaginationItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import SiteItem, PaginationItem logger = logging.getLogger("tableau.endpoint.sites") diff --git a/tableauserverclient/server/endpoint/subscriptions_endpoint.py b/tableauserverclient/server/endpoint/subscriptions_endpoint.py index 6b929524e..a81a2fbf0 100644 --- a/tableauserverclient/server/endpoint/subscriptions_endpoint.py +++ b/tableauserverclient/server/endpoint/subscriptions_endpoint.py @@ -2,7 +2,8 @@ from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError -from .. import RequestFactory, SubscriptionItem, PaginationItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import SubscriptionItem, PaginationItem logger = logging.getLogger("tableau.endpoint.subscriptions") diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index e41ab07ca..e51f885d7 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -4,7 +4,8 @@ from .endpoint import api, Endpoint from .exceptions import MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint -from .. import RequestFactory, TableItem, ColumnItem, PaginationItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import TableItem, ColumnItem, PaginationItem from ..pager import Pager logger = logging.getLogger("tableau.endpoint.tables") diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index a70480b91..b903ac634 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -2,7 +2,8 @@ from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError -from .. import TaskItem, PaginationItem, RequestFactory +from tableauserverclient.models import TaskItem, PaginationItem +from tableauserverclient.server import RequestFactory logger = logging.getLogger("tableau.endpoint.tasks") diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 3faf4d173..5a9c74619 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -1,16 +1,13 @@ import copy import logging -import os -from typing import List, Optional, Tuple, Union +from typing import List, Optional, Tuple from .endpoint import QuerysetEndpoint, api from .exceptions import MissingRequiredFieldError, ServerResponseError -from .. import RequestFactory, RequestOptions, UserItem, WorkbookItem, PaginationItem, GroupItem +from tableauserverclient.server import RequestFactory, RequestOptions +from tableauserverclient.models import UserItem, WorkbookItem, PaginationItem, GroupItem from ..pager import Pager -# duplicate defined in workbooks_endpoint -FilePath = Union[str, os.PathLike] - logger = logging.getLogger("tableau.endpoint.users") diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 06cc08349..c060298ba 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -5,7 +5,7 @@ from .exceptions import MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint from .resource_tagger import _ResourceTagger -from .. import ViewItem, PaginationItem +from tableauserverclient.models import ViewItem, PaginationItem logger = logging.getLogger("tableau.endpoint.views") diff --git a/tableauserverclient/server/endpoint/webhooks_endpoint.py b/tableauserverclient/server/endpoint/webhooks_endpoint.py index b28f3e5f1..69a958988 100644 --- a/tableauserverclient/server/endpoint/webhooks_endpoint.py +++ b/tableauserverclient/server/endpoint/webhooks_endpoint.py @@ -1,8 +1,8 @@ import logging from .endpoint import Endpoint, api -from .. import RequestFactory -from ...models import WebhookItem, PaginationItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import WebhookItem, PaginationItem logger = logging.getLogger("tableau.endpoint.webhooks") diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index b7df3fcbb..295a4941f 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -5,29 +5,21 @@ import os from contextlib import closing from pathlib import Path -from typing import ( - List, - Optional, - Sequence, - Tuple, - TYPE_CHECKING, - Union, -) from .endpoint import QuerysetEndpoint, api, parameter_added_in from .exceptions import InternalServerError, MissingRequiredFieldError -from ...helpers import redact_xml from .permissions_endpoint import _PermissionsEndpoint from .resource_tagger import _ResourceTagger -from .. import RequestFactory, WorkbookItem, ConnectionItem, ViewItem, PaginationItem -from ...filesys_helpers import ( + +from tableauserverclient.filesys_helpers import ( to_filename, make_download_path, get_file_type, get_file_object_size, ) -from ...models.job_item import JobItem -from ...models.revision_item import RevisionItem +from tableauserverclient.helpers import redact_xml +from tableauserverclient.models import WorkbookItem, ConnectionItem, ViewItem, PaginationItem, JobItem, RevisionItem +from tableauserverclient.server import RequestFactory from typing import ( List, @@ -39,10 +31,9 @@ ) if TYPE_CHECKING: - from ..server import Server - from ..request_options import RequestOptions - from .. import DatasourceItem - from ...models.connection_credentials import ConnectionCredentials + from tableauserverclient.server import Server + from tableauserverclient.server.request_options import RequestOptions + from tableauserverclient.models import DatasourceItem, ConnectionCredentials from .schedules_endpoint import AddResponse io_types_r = (io.BytesIO, io.BufferedReader) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 720eb4085..b19c3cc56 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1,29 +1,12 @@ -from os import name import xml.etree.ElementTree as ET from typing import Any, Dict, Iterable, List, Optional, Tuple, TYPE_CHECKING from requests.packages.urllib3.fields import RequestField from requests.packages.urllib3.filepost import encode_multipart_formdata -from tableauserverclient.models.metric_item import MetricItem - -from ..models import ConnectionCredentials -from ..models import ConnectionItem -from ..models import DataAlertItem -from ..models import FlowItem -from ..models import ProjectItem -from ..models import SiteItem -from ..models import SubscriptionItem -from ..models import TaskItem, UserItem, GroupItem, PermissionsRule, FavoriteItem -from ..models import WebhookItem +from tableauserverclient.models import * if TYPE_CHECKING: - from ..models import SubscriptionItem - from ..models import DataAlertItem - from ..models import FlowItem - from ..models import ConnectionItem - from ..models import SiteItem - from ..models import ProjectItem from tableauserverclient.server import Server @@ -1019,6 +1002,8 @@ def update_req(self, xml_request: ET.Element, connection_item: "ConnectionItem") connection_element.attrib["password"] = connection_item.password if connection_item.embed_password is not None: connection_element.attrib["embedPassword"] = str(connection_item.embed_password).lower() + if connection_item.query_tagging is not None: + connection_element.attrib["queryTaggingEnabled"] = str(connection_item.query_tagging).lower() class TaskRequest(object): @@ -1144,10 +1129,21 @@ def update_req(self, xml_request: ET.Element, metric_item: MetricItem) -> bytes: return ET.tostring(xml_request) +class CustomViewRequest(object): + @_tsrequest_wrapped + def update_req(self, xml_request: ET.Element, custom_view_item: CustomViewItem): + updating_element = ET.SubElement(xml_request, "customView") + if custom_view_item.owner is not None and custom_view_item.owner.id is not None: + ET.SubElement(updating_element, "owner", {"id": custom_view_item.owner.id}) + if custom_view_item.name is not None: + updating_element.attrib["name"] = custom_view_item.name + + class RequestFactory(object): Auth = AuthRequest() Connection = Connection() Column = ColumnRequest() + CustomView = CustomViewRequest() DataAlert = DataAlertRequest() Datasource = DatasourceRequest() Database = DatabaseRequest() diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index f4ed8fd3c..baedd74de 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,4 +1,4 @@ -from ..models.property_decorators import property_is_int +from tableauserverclient.models.property_decorators import property_is_int import logging logger = logging.getLogger("tableau.request_options") diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index d2a8b933b..887b9de6d 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -1,11 +1,12 @@ import logging -import warnings import requests import urllib3 from defusedxml.ElementTree import fromstring, ParseError from packaging.version import Version + +from . import CustomViews from .endpoint import ( Sites, Views, @@ -48,8 +49,9 @@ "9.1": "2.0", "9.0": "2.0", } + minimum_supported_server_version = "2.3" -default_server_version = "2.3" +default_server_version = "2.4" # first version that dropped the legacy auth endpoint class Server(object): @@ -95,6 +97,9 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self._namespace = Namespace() self.flow_runs = FlowRuns(self) self.metrics = Metrics(self) + self.custom_views = CustomViews(self) + + self.logger = logging.getLogger("TSC.server") self._session = self._session_factory() self._http_options = dict() # must set this before making a server call @@ -110,11 +115,14 @@ def __init__(self, server_address, use_server_version=False, http_options=None, def validate_connection_settings(self): try: Endpoint(self).set_parameters(self._http_options, None, None, None, None) + if not self._server_address.startswith("http://") and not self._server_address.startswith("https://"): + self._server_address = "http://" + self._server_address + self._session.prepare_request(requests.Request("GET", url=self._server_address, params=self._http_options)) except Exception as req_ex: raise ValueError("Server connection settings not valid", req_ex) def __repr__(self): - return " [Connection: {}, {}]".format(self.baseurl, self.server_info.serverInfo) + return "".format(self.baseurl, self.server_info.serverInfo) def add_http_options(self, options_dict: dict): try: @@ -122,8 +130,7 @@ def add_http_options(self, options_dict: dict): if "verify" in options_dict.keys() and self._http_options.get("verify") is False: urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) # would be nice if you could turn them back on - except BaseException as be: - print(be) + except Exception as be: # expected errors on invalid input: # 'set' object has no attribute 'keys', 'list' object has no attribute 'keys' # TypeError: cannot convert dictionary update sequence element #0 to a sequence (input is a tuple) @@ -144,43 +151,43 @@ def _set_auth(self, site_id, user_id, auth_token): self._auth_token = auth_token def _get_legacy_version(self): - dest = Endpoint(self) - response = dest._make_request(method=self.session.get, url=self.server_address + "/auth?format=xml") + # the serverInfo call was introduced in 2.4, earlier than that we have this different call + response = self._session.get(self.server_address + "/auth?format=xml") try: info_xml = fromstring(response.content) except ParseError as parseError: - logging.getLogger("TSC.server").info(parseError) - logging.getLogger("TSC.server").info( - "Could not read server version info. The server may not be running or configured." - ) + self.logger.info(parseError) + self.logger.info("Could not read server version info. The server may not be running or configured.") return self.version prod_version = info_xml.find(".//product_version").text - version = _PRODUCT_TO_REST_VERSION.get(prod_version, "2.1") # 2.1 + version = _PRODUCT_TO_REST_VERSION.get(prod_version, minimum_supported_server_version) return version def _determine_highest_version(self): try: old_version = self.version - self.version = "2.4" version = self.server_info.get().rest_api_version - except ServerInfoEndpointNotFoundError: + except ServerInfoEndpointNotFoundError as e: + self.logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) version = self._get_legacy_version() - except BaseException: + except EndpointUnavailableError as e: + self.logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) version = self._get_legacy_version() - - self.version = old_version - - return version + except Exception as e: + self.logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) + version = None + self.logger.info("versions: {}, {}".format(version, old_version)) + return version or old_version def use_server_version(self): self.version = self._determine_highest_version() def use_highest_version(self): self.use_server_version() - warnings.warn("use use_server_version instead", DeprecationWarning) + self.logger.info("use use_server_version instead", DeprecationWarning) def check_at_least_version(self, target: str): - server_version = Version(self.version or "0.0") + server_version = Version(self.version or "2.4") target_version = Version(target) return server_version >= target_version diff --git a/test/assets/custom_view_get.xml b/test/assets/custom_view_get.xml new file mode 100644 index 000000000..67e342f30 --- /dev/null +++ b/test/assets/custom_view_get.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/test/assets/custom_view_get_id.xml b/test/assets/custom_view_get_id.xml new file mode 100644 index 000000000..14e589b8d --- /dev/null +++ b/test/assets/custom_view_get_id.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/test/assets/custom_view_update.xml b/test/assets/custom_view_update.xml new file mode 100644 index 000000000..5ab85bc05 --- /dev/null +++ b/test/assets/custom_view_update.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/test/assets/server_info_get.xml b/test/assets/server_info_get.xml index ce4e0b322..94218502a 100644 --- a/test/assets/server_info_get.xml +++ b/test/assets/server_info_get.xml @@ -1,6 +1,6 @@ 10.1.0 -2.4 +3.10 - \ No newline at end of file + diff --git a/test/http/test_http_requests.py b/test/http/test_http_requests.py index bf9292dec..ce845502d 100644 --- a/test/http/test_http_requests.py +++ b/test/http/test_http_requests.py @@ -11,6 +11,8 @@ def mocked_requests_get(*args, **kwargs): class MockResponse: def __init__(self, status_code): + self.headers = {} + self.encoding = None self.content = ( "" "" @@ -43,9 +45,9 @@ def test_init_server_model_valid_https_server_name_works(self): def test_init_server_model_bad_server_name_not_version_check(self): server = TSC.Server("fake-url", use_server_version=False) - def test_init_server_model_bad_server_name_do_version_check(self): - with self.assertRaises(requests.exceptions.ConnectionError): - server = TSC.Server("fake-url", use_server_version=True) + @mock.patch("requests.sessions.Session.get", side_effect=mocked_requests_get) + def test_init_server_model_bad_server_name_do_version_check(self, mock_get): + server = TSC.Server("fake-url", use_server_version=True) def test_init_server_model_bad_server_name_not_version_check_random_options(self): # with self.assertRaises(MissingSchema): diff --git a/test/models/_models.py b/test/models/_models.py new file mode 100644 index 000000000..a1630da9c --- /dev/null +++ b/test/models/_models.py @@ -0,0 +1,61 @@ +from tableauserverclient import * + +# mmm. why aren't these available in the tsc namespace? +from tableauserverclient.models import ( + DataAccelerationReportItem, + FavoriteItem, + Credentials, + ServerInfoItem, + Resource, + TableauItem, + plural_type, +) + + +def get_defined_models(): + # not clever: copied from tsc/models/__init__.py + return [ + ColumnItem, + ConnectionCredentials, + ConnectionItem, + DataAccelerationReportItem, + DataAlertItem, + DatabaseItem, + DatasourceItem, + DQWItem, + UnpopulatedPropertyError, + FavoriteItem, + FlowItem, + FlowRunItem, + GroupItem, + IntervalItem, + DailyInterval, + WeeklyInterval, + MonthlyInterval, + HourlyInterval, + JobItem, + BackgroundJobItem, + MetricItem, + PaginationItem, + PermissionsRule, + Permission, + ProjectItem, + RevisionItem, + ScheduleItem, + ServerInfoItem, + SiteItem, + SubscriptionItem, + TableItem, + Credentials, + TableauAuth, + PersonalAccessTokenAuth, + Resource, + TableauItem, + plural_type, + Target, + TaskItem, + UserItem, + ViewItem, + WebhookItem, + WorkbookItem, + ] diff --git a/test/models/test_repr.py b/test/models/test_repr.py new file mode 100644 index 000000000..f3da9fde2 --- /dev/null +++ b/test/models/test_repr.py @@ -0,0 +1,40 @@ +import pytest + +from unittest import TestCase +import _models + + +# ensure that all models have a __repr__ method implemented +class TestAllModels(TestCase): + + """ + ColumnItem wrapper_descriptor + ConnectionCredentials wrapper_descriptor + DataAccelerationReportItem wrapper_descriptor + DatabaseItem wrapper_descriptor + DQWItem wrapper_descriptor + UnpopulatedPropertyError wrapper_descriptor + FavoriteItem wrapper_descriptor + FlowRunItem wrapper_descriptor + IntervalItem wrapper_descriptor + DailyInterval wrapper_descriptor + WeeklyInterval wrapper_descriptor + MonthlyInterval wrapper_descriptor + HourlyInterval wrapper_descriptor + BackgroundJobItem wrapper_descriptor + PaginationItem wrapper_descriptor + Permission wrapper_descriptor + ServerInfoItem wrapper_descriptor + SiteItem wrapper_descriptor + TableItem wrapper_descriptor + Resource wrapper_descriptor + """ + + # not all models have __repr__ yet: see above list + @pytest.mark.xfail() + def test_repr_is_implemented(self): + m = _models.get_defined_models() + for model in m: + with self.subTest(model.__name__, model=model): + print(model.__name__, type(model.__repr__).__name__) + self.assertEqual(type(model.__repr__).__name__, "function") diff --git a/test/test_connection_.py b/test/test_connection_.py new file mode 100644 index 000000000..47b796ebe --- /dev/null +++ b/test/test_connection_.py @@ -0,0 +1,34 @@ +import unittest +import tableauserverclient as TSC + + +class DatasourceModelTests(unittest.TestCase): + def test_require_boolean_query_tag_fails(self): + conn = TSC.ConnectionItem() + conn._connection_type = "postgres" + with self.assertRaises(ValueError): + conn.query_tagging = "no" + + def test_set_query_tag_normal_conn(self): + conn = TSC.ConnectionItem() + conn._connection_type = "postgres" + conn.query_tagging = True + self.assertEqual(conn.query_tagging, True) + + def test_ignore_query_tag_for_hyper(self): + conn = TSC.ConnectionItem() + conn._connection_type = "hyper" + conn.query_tagging = True + self.assertEqual(conn.query_tagging, None) + + def test_ignore_query_tag_for_teradata(self): + conn = TSC.ConnectionItem() + conn._connection_type = "teradata" + conn.query_tagging = True + self.assertEqual(conn.query_tagging, None) + + def test_ignore_query_tag_for_snowflake(self): + conn = TSC.ConnectionItem() + conn._connection_type = "snowflake" + conn.query_tagging = True + self.assertEqual(conn.query_tagging, None) diff --git a/test/test_custom_view.py b/test/test_custom_view.py new file mode 100644 index 000000000..c1fe8c407 --- /dev/null +++ b/test/test_custom_view.py @@ -0,0 +1,133 @@ +import os +import unittest + +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") + +GET_XML = os.path.join(TEST_ASSET_DIR, "custom_view_get.xml") +GET_XML_ID = os.path.join(TEST_ASSET_DIR, "custom_view_get_id.xml") +POPULATE_PREVIEW_IMAGE = os.path.join(TEST_ASSET_DIR, "Sample View Image.png") +CUSTOM_VIEW_UPDATE_XML = os.path.join(TEST_ASSET_DIR, "custom_view_update.xml") + + +class CustomViewTests(unittest.TestCase): + def setUp(self): + self.server = TSC.Server("http://test", False) + self.server.version = "3.19" # custom views only introduced in 3.19 + + # Fake sign in + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + + self.baseurl = self.server.custom_views.baseurl + + def test_get(self) -> None: + with open(GET_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + print(response_xml) + with requests_mock.mock() as m: + m.get(self.baseurl, text=response_xml) + all_views, pagination_item = self.server.custom_views.get() + + self.assertEqual(2, pagination_item.total_available) + self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", all_views[0].id) + self.assertEqual("ENDANGERED SAFARI", all_views[0].name) + self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", all_views[0].content_url) + self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", all_views[0].workbook.id) + 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.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)) + + def test_get_by_id(self) -> None: + with open(GET_XML_ID, "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", text=response_xml) + view: TSC.CustomViewItem = self.server.custom_views.get_by_id("d79634e1-6063-4ec9-95ff-50acbf609ff5") + + self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", view.id) + self.assertEqual("ENDANGERED SAFARI", view.name) + self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", view.content_url) + if view.workbook: + self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", view.workbook.id) + if view.owner: + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", view.owner.id) + if view.view: + self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", view.view.id) + self.assertEqual("2002-05-30T09:00:00Z", format_datetime(view.created_at)) + self.assertEqual("2002-06-05T08:00:59Z", format_datetime(view.updated_at)) + + def test_get_by_id_missing_id(self) -> None: + self.assertRaises(TSC.MissingRequiredFieldError, self.server.custom_views.get_by_id, None) + + def test_get_before_signin(self) -> None: + self.server._auth_token = None + self.assertRaises(TSC.NotSignedInError, self.server.custom_views.get) + + def test_populate_image(self) -> None: + with open(POPULATE_PREVIEW_IMAGE, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image", content=response) + single_view = TSC.CustomViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + self.server.custom_views.populate_image(single_view) + self.assertEqual(response, single_view.image) + + def test_populate_image_with_options(self) -> None: + with open(POPULATE_PREVIEW_IMAGE, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get( + self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?resolution=high&maxAge=10", content=response + ) + single_view = TSC.CustomViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + req_option = TSC.ImageRequestOptions(imageresolution=TSC.ImageRequestOptions.Resolution.High, maxage=10) + self.server.custom_views.populate_image(single_view, req_option) + self.assertEqual(response, single_view.image) + + def test_populate_image_missing_id(self) -> None: + single_view = TSC.CustomViewItem() + single_view._id = None + self.assertRaises(TSC.MissingRequiredFieldError, self.server.custom_views.populate_image, single_view) + + def test_delete(self) -> None: + with requests_mock.mock() as m: + m.delete(self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42", status_code=204) + self.server.custom_views.delete("3cc6cd06-89ce-4fdc-b935-5294135d6d42") + + def test_delete_missing_id(self) -> None: + self.assertRaises(ValueError, self.server.custom_views.delete, "") + + def test_update(self) -> None: + with open(CUSTOM_VIEW_UPDATE_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) + the_custom_view = TSC.CustomViewItem("1d0304cd-3796-429f-b815-7258370b9b74", name="Best test ever") + the_custom_view._id = "1f951daf-4061-451a-9df1-69a8062664f2" + the_custom_view.owner = TSC.UserItem() + the_custom_view.owner.id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + the_custom_view = self.server.custom_views.update(the_custom_view) + + self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", the_custom_view.id) + if the_custom_view.owner: + self.assertEqual("dd2239f6-ddf1-4107-981a-4cf94e415794", the_custom_view.owner.id) + self.assertEqual("Best test ever", the_custom_view.name) + + def test_update_missing_id(self) -> None: + cv = TSC.CustomViewItem(name="test") + self.assertRaises(TSC.MissingRequiredFieldError, self.server.custom_views.update, cv) diff --git a/test/test_datasource_model.py b/test/test_datasource_model.py index 81a26b068..2360574ec 100644 --- a/test/test_datasource_model.py +++ b/test/test_datasource_model.py @@ -1,5 +1,4 @@ import unittest - import tableauserverclient as TSC @@ -9,3 +8,13 @@ def test_invalid_project_id(self): datasource = TSC.DatasourceItem("10") with self.assertRaises(ValueError): datasource.project_id = None + + def test_require_boolean_flag_bridge_fail(self): + datasource = TSC.DatasourceItem("10") + with self.assertRaises(ValueError): + datasource.use_remote_query_agent = "yes" + + def test_require_boolean_flag_bridge_ok(self): + datasource = TSC.DatasourceItem("10") + datasource.use_remote_query_agent = True + self.assertEqual(datasource.use_remote_query_agent, True) diff --git a/test/test_server_info.py b/test/test_server_info.py index 80b071e75..1cf190ecd 100644 --- a/test/test_server_info.py +++ b/test/test_server_info.py @@ -28,7 +28,7 @@ def test_server_info_get(self): self.assertEqual("10.1.0", actual.product_version) self.assertEqual("10100.16.1024.2100", actual.build_number) - self.assertEqual("2.4", actual.rest_api_version) + self.assertEqual("3.10", actual.rest_api_version) def test_server_info_use_highest_version_downgrades(self): with open(SERVER_INFO_AUTH_INFO_XML, "rb") as f: @@ -42,18 +42,19 @@ def test_server_info_use_highest_version_downgrades(self): m.get(self.server.server_address + "/api/2.4/serverInfo", text=si_response_xml, status_code=404) m.get(self.server.server_address + "/auth?format=xml", text=auth_response_xml) self.server.use_server_version() + # does server-version[9.2] lookup in PRODUCT_TO_REST_VERSION self.assertEqual(self.server.version, "2.2") def test_server_info_use_highest_version_upgrades(self): with open(SERVER_INFO_GET_XML, "rb") as f: si_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.server.server_address + "/api/2.4/serverInfo", text=si_response_xml) + m.get(self.server.server_address + "/api/2.8/serverInfo", text=si_response_xml) # Pretend we're old - self.server.version = "2.0" + self.server.version = "2.8" self.server.use_server_version() - # Did we upgrade to 2.4? - self.assertEqual(self.server.version, "2.4") + # Did we upgrade to 3.10? + self.assertEqual(self.server.version, "3.10") def test_server_use_server_version_flag(self): with open(SERVER_INFO_25_XML, "rb") as f: diff --git a/test/test_user_model.py b/test/test_user_model.py index fcb9b7f90..d0997b9ff 100644 --- a/test/test_user_model.py +++ b/test/test_user_model.py @@ -10,16 +10,6 @@ class UserModelTests(unittest.TestCase): - def test_invalid_name(self): - self.assertRaises(ValueError, TSC.UserItem, None, TSC.UserItem.Roles.Publisher) - self.assertRaises(ValueError, TSC.UserItem, "", TSC.UserItem.Roles.Publisher) - user = TSC.UserItem("me", TSC.UserItem.Roles.Publisher) - with self.assertRaises(ValueError): - user.name = None - - with self.assertRaises(ValueError): - user.name = "" - def test_invalid_auth_setting(self): user = TSC.UserItem("me", TSC.UserItem.Roles.Publisher) with self.assertRaises(ValueError): diff --git a/test/test_workbook.py b/test/test_workbook.py index 8711ba15e..5114ce1b8 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -1,20 +1,15 @@ import os import re import requests_mock -import tableauserverclient as TSC import tempfile import unittest -import xml.etree.ElementTree as ET - from defusedxml.ElementTree import fromstring from io import BytesIO from pathlib import Path import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime -from tableauserverclient.models.group_item import GroupItem -from tableauserverclient.models.permissions_item import PermissionsRule -from tableauserverclient.models.user_item import UserItem +from tableauserverclient.models import UserItem, GroupItem, PermissionsRule from tableauserverclient.server.endpoint.exceptions import InternalServerError from tableauserverclient.server.request_factory import RequestFactory from ._utils import asset From fc9568de2e9b5e566120c2f20fe2f678df6a782e Mon Sep 17 00:00:00 2001 From: Tim Payne <47423639+ma7tcsp@users.noreply.github.com> Date: Wed, 12 Apr 2023 20:26:42 +0100 Subject: [PATCH 029/296] Update user_item.py (#1217) TableauIDWithMFA added to the user_item model to allow creating users on Tableau Cloud with MFA enabled and to keep in sync with REST API. Fixing Issue #1216 --- tableauserverclient/models/user_item.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 5e3d18fa6..a12f4b557 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -45,6 +45,7 @@ class Roles: class Auth: OpenID = "OpenID" SAML = "SAML" + TableauIDWithMFA = "TableauIDWithMFA" ServerDefault = "ServerDefault" def __init__( From 4fb61806fc3e0b4fe7804c0de9d2a3f3f02a054b Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 12 Apr 2023 18:22:33 -0700 Subject: [PATCH 030/296] Jac/small things (#1215) https://github.com/tableau/server-client-python/issues/1210 https://github.com/tableau/server-client-python/issues/1087 https://github.com/tableau/server-client-python/issues/1058 https://github.com/tableau/server-client-python/issues/456 https://github.com/tableau/server-client-python/issues/1209 --- tableauserverclient/models/datasource_item.py | 7 +++---- .../server/endpoint/workbooks_endpoint.py | 3 ++- tableauserverclient/server/request_factory.py | 19 ++++++++++++++----- tableauserverclient/server/request_options.py | 3 ++- test/test_datasource_model.py | 8 +++----- test/test_view.py | 16 ++++++++++++++++ 6 files changed, 40 insertions(+), 16 deletions(-) diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index b5568a778..dbaa0ff91 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -32,7 +32,7 @@ def __repr__(self): self.project_id, ) - def __init__(self, project_id: str, name: Optional[str] = None) -> None: + def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) -> None: self._ask_data_enablement = None self._certified = None self._certification_note = None @@ -135,12 +135,11 @@ def id(self) -> Optional[str]: return self._id @property - def project_id(self) -> str: + def project_id(self) -> Optional[str]: return self._project_id @project_id.setter - @property_not_nullable - def project_id(self, value: str): + def project_id(self, value: Optional[str]): self._project_id = value @property diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 295a4941f..5e2784b55 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -309,6 +309,7 @@ def publish( 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 @@ -412,7 +413,7 @@ def publish( # Send the publishing request to server try: - server_response = self.post_request(url, xml_request, content_type) + server_response = self.post_request(url, xml_request, content_type, parameters) except InternalServerError as err: if err.code == 504 and not as_job: err.content = "Timeout error while publishing. Please use asynchronous publishing to avoid timeouts." diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index b19c3cc56..050874c91 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -9,6 +9,8 @@ if TYPE_CHECKING: from tableauserverclient.server import Server +# this file could be largely replaced if we were willing to import the huge file from generateDS + def _add_multipart(parts: Dict) -> Tuple[Any, str]: mime_multipart_parts = list() @@ -146,10 +148,11 @@ def update_req(self, database_item): class DatasourceRequest(object): - def _generate_xml(self, datasource_item, connection_credentials=None, connections=None): + def _generate_xml(self, datasource_item: DatasourceItem, connection_credentials=None, connections=None): xml_request = ET.Element("tsRequest") datasource_element = ET.SubElement(xml_request, "datasource") - datasource_element.attrib["name"] = datasource_item.name + if datasource_item.name: + datasource_element.attrib["name"] = datasource_item.name if datasource_item.description: datasource_element.attrib["description"] = str(datasource_item.description) if datasource_item.use_remote_query_agent is not None: @@ -157,10 +160,16 @@ def _generate_xml(self, datasource_item, connection_credentials=None, connection if datasource_item.ask_data_enablement: ask_data_element = ET.SubElement(datasource_element, "askData") - ask_data_element.attrib["enablement"] = datasource_item.ask_data_enablement + ask_data_element.attrib["enablement"] = datasource_item.ask_data_enablement.__str__() - project_element = ET.SubElement(datasource_element, "project") - project_element.attrib["id"] = datasource_item.project_id + if datasource_item.certified: + datasource_element.attrib["isCertified"] = datasource_item.certified.__str__() + if datasource_item.certification_note: + datasource_element.attrib["certificationNote"] = datasource_item.certification_note + + if datasource_item.project_id: + project_element = ET.SubElement(datasource_element, "project") + project_element.attrib["id"] = datasource_item.project_id if connection_credentials is not None and connections is not None: raise RuntimeError("You cannot set both `connections` and `connection_credentials`") diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index baedd74de..299b9db2f 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -38,6 +38,7 @@ class Operator: class Field: Args = "args" CompletedAt = "completedAt" + ContentUrl = "contentUrl" CreatedAt = "createdAt" DomainName = "domainName" DomainNickname = "domainNickname" @@ -147,7 +148,7 @@ def get_query_params(self): return params -class ExcelRequestOptions(RequestOptionsBase): +class ExcelRequestOptions(_FilterOptionsBase): def __init__(self, maxage: int = -1) -> None: super().__init__() self.max_age = maxage diff --git a/test/test_datasource_model.py b/test/test_datasource_model.py index 2360574ec..655284194 100644 --- a/test/test_datasource_model.py +++ b/test/test_datasource_model.py @@ -3,11 +3,9 @@ class DatasourceModelTests(unittest.TestCase): - def test_invalid_project_id(self): - self.assertRaises(ValueError, TSC.DatasourceItem, None) - datasource = TSC.DatasourceItem("10") - with self.assertRaises(ValueError): - datasource.project_id = None + def test_nullable_project_id(self): + datasource = TSC.DatasourceItem(name="10") + self.assertEqual(datasource.project_id, None) def test_require_boolean_flag_bridge_fail(self): datasource = TSC.DatasourceItem("10") diff --git a/test/test_view.py b/test/test_view.py index f5d3db47b..1459150bb 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -299,3 +299,19 @@ def test_populate_excel(self) -> None: excel_file = b"".join(single_view.excel) self.assertEqual(response, excel_file) + + def test_filter_excel(self) -> None: + self.server.version = "3.8" + self.baseurl = self.server.views.baseurl + with open(POPULATE_EXCEL, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/crosstab/excel?maxAge=1", content=response) + single_view = TSC.ViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + request_option = TSC.ExcelRequestOptions(maxage=1) + request_option.vf("stuff", "1") + self.server.views.populate_excel(single_view, request_option) + + excel_file = b"".join(single_view.excel) + self.assertEqual(response, excel_file) From 3cc28be8e18af0f36dfd390c7c3a306e5d90f6a0 Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 18 Apr 2023 19:59:35 -0700 Subject: [PATCH 031/296] run long requests on second thread (#1212) * run long requests on second thread * improve chunked upload requests * begin extracting constants for user editing * centrally configured logger --- .gitignore | 1 + samples/add_default_permission.py | 10 +- samples/create_group.py | 10 +- samples/create_project.py | 10 +- samples/create_schedules.py | 10 +- samples/explore_datasource.py | 10 +- samples/explore_site.py | 10 +- samples/explore_webhooks.py | 10 +- samples/explore_workbook.py | 10 +- samples/export.py | 10 +- samples/extracts.py | 10 +- samples/filter_sort_groups.py | 10 +- samples/filter_sort_projects.py | 10 +- samples/initialize_server.py | 14 +-- samples/kill_all_jobs.py | 10 +- samples/list.py | 10 +- samples/login.py | 21 +++- samples/metadata_query.py | 10 +- samples/move_workbook_projects.py | 14 +-- samples/move_workbook_sites.py | 14 +-- samples/pagination_sample.py | 10 +- samples/publish_datasource.py | 40 +++++-- samples/publish_workbook.py | 12 +- samples/query_permissions.py | 10 +- samples/refresh.py | 10 +- samples/refresh_tasks.py | 10 +- samples/set_refresh_schedule.py | 10 +- samples/smoke_test.py | 10 +- samples/update_connection.py | 10 +- samples/update_datasource_data.py | 10 +- tableauserverclient/config.py | 13 +++ tableauserverclient/datetime_helpers.py | 4 + tableauserverclient/helpers/logging.py | 6 + tableauserverclient/models/connection_item.py | 2 +- tableauserverclient/models/favorites_item.py | 3 +- tableauserverclient/models/fileupload_item.py | 4 +- .../models/permissions_item.py | 2 +- .../models/server_info_item.py | 2 +- tableauserverclient/server/__init__.py | 4 +- .../server/endpoint/__init__.py | 6 +- .../server/endpoint/auth_endpoint.py | 2 +- .../server/endpoint/custom_views_endpoint.py | 2 +- .../data_acceleration_report_endpoint.py | 2 +- .../server/endpoint/data_alert_endpoint.py | 2 +- .../server/endpoint/databases_endpoint.py | 2 +- .../server/endpoint/datasources_endpoint.py | 36 +++--- .../endpoint/default_permissions_endpoint.py | 2 +- .../server/endpoint/dqw_endpoint.py | 2 +- .../server/endpoint/endpoint.py | 109 ++++++++++++++++-- .../server/endpoint/exceptions.py | 6 +- .../server/endpoint/favorites_endpoint.py | 2 +- .../server/endpoint/fileuploads_endpoint.py | 21 ++-- .../server/endpoint/flow_runs_endpoint.py | 2 +- .../server/endpoint/flows_endpoint.py | 10 +- .../server/endpoint/groups_endpoint.py | 2 +- .../server/endpoint/jobs_endpoint.py | 2 +- .../server/endpoint/metadata_endpoint.py | 2 +- .../server/endpoint/metrics_endpoint.py | 2 +- .../server/endpoint/permissions_endpoint.py | 2 +- .../server/endpoint/projects_endpoint.py | 2 +- .../server/endpoint/resource_tagger.py | 6 +- .../server/endpoint/schedules_endpoint.py | 3 +- .../server/endpoint/server_info_endpoint.py | 6 +- .../server/endpoint/sites_endpoint.py | 2 +- .../server/endpoint/subscriptions_endpoint.py | 2 +- .../server/endpoint/tables_endpoint.py | 2 +- .../server/endpoint/tasks_endpoint.py | 2 +- .../server/endpoint/users_endpoint.py | 2 +- .../server/endpoint/views_endpoint.py | 2 +- .../server/endpoint/webhooks_endpoint.py | 2 +- .../server/endpoint/workbooks_endpoint.py | 3 +- tableauserverclient/server/exceptions.py | 9 +- tableauserverclient/server/request_options.py | 2 +- tableauserverclient/server/server.py | 25 ++-- test/test_auth.py | 6 +- test/test_datasource.py | 6 +- test/test_endpoint.py | 25 +++- test/test_fileuploads.py | 4 +- test/test_request_option.py | 6 +- test/test_webhook.py | 3 +- 80 files changed, 395 insertions(+), 327 deletions(-) create mode 100644 tableauserverclient/config.py create mode 100644 tableauserverclient/helpers/logging.py diff --git a/.gitignore b/.gitignore index d8caf99a9..f0226c065 100644 --- a/.gitignore +++ b/.gitignore @@ -84,6 +84,7 @@ celerybeat-schedule # dotenv .env +env.py # virtualenv venv/ diff --git a/samples/add_default_permission.py b/samples/add_default_permission.py index 8a87c1fd6..5a450e8ab 100644 --- a/samples/add_default_permission.py +++ b/samples/add_default_permission.py @@ -18,14 +18,10 @@ def main(): parser = argparse.ArgumentParser(description="Add workbook default permissions for a given project.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/create_group.py b/samples/create_group.py index 2229f7f26..f4c6a9ca9 100644 --- a/samples/create_group.py +++ b/samples/create_group.py @@ -20,14 +20,10 @@ def main(): parser = argparse.ArgumentParser(description="Creates a sample user group.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/create_project.py b/samples/create_project.py index 8b2ec3354..611dbe366 100644 --- a/samples/create_project.py +++ b/samples/create_project.py @@ -28,14 +28,10 @@ def create_project(server, project_item, samples=False): def main(): parser = argparse.ArgumentParser(description="Create new projects.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/create_schedules.py b/samples/create_schedules.py index f193352de..dee088571 100644 --- a/samples/create_schedules.py +++ b/samples/create_schedules.py @@ -17,14 +17,10 @@ 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", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/explore_datasource.py b/samples/explore_datasource.py index aafbe167c..fb45cb45e 100644 --- a/samples/explore_datasource.py +++ b/samples/explore_datasource.py @@ -18,14 +18,10 @@ def main(): parser = argparse.ArgumentParser(description="Explore datasource functions supported by the Server API.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/explore_site.py b/samples/explore_site.py index a181abfec..a2274f1a7 100644 --- a/samples/explore_site.py +++ b/samples/explore_site.py @@ -14,14 +14,10 @@ def main(): parser = argparse.ArgumentParser(description="Explore site updates by the Server API.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/explore_webhooks.py b/samples/explore_webhooks.py index 47e59ac06..77802b1db 100644 --- a/samples/explore_webhooks.py +++ b/samples/explore_webhooks.py @@ -19,14 +19,10 @@ def main(): parser = argparse.ArgumentParser(description="Explore webhook functions supported by the Server API.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/explore_workbook.py b/samples/explore_workbook.py index f242ace70..c61b9b637 100644 --- a/samples/explore_workbook.py +++ b/samples/explore_workbook.py @@ -19,14 +19,10 @@ def main(): parser = argparse.ArgumentParser(description="Explore workbook functions supported by the Server API.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/export.py b/samples/export.py index 4c26770b9..f2783fa6e 100644 --- a/samples/export.py +++ b/samples/export.py @@ -14,14 +14,10 @@ def main(): parser = argparse.ArgumentParser(description="Export a view as an image, PDF, or CSV") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/extracts.py b/samples/extracts.py index c77da89d0..9bd87a473 100644 --- a/samples/extracts.py +++ b/samples/extracts.py @@ -19,14 +19,10 @@ def main(): parser = argparse.ArgumentParser(description="Explore extract functions supported by the Server API.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", help="site name") - parser.add_argument( - "--token-name", "-tn", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-tv", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-tn", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-tv", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/filter_sort_groups.py b/samples/filter_sort_groups.py index 984d8d344..042af32e2 100644 --- a/samples/filter_sort_groups.py +++ b/samples/filter_sort_groups.py @@ -26,14 +26,10 @@ def create_example_group(group_name="Example Group", server=None): def main(): parser = argparse.ArgumentParser(description="Filter and sort groups.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/filter_sort_projects.py b/samples/filter_sort_projects.py index 608f472ba..7aa62a5c1 100644 --- a/samples/filter_sort_projects.py +++ b/samples/filter_sort_projects.py @@ -29,14 +29,10 @@ def create_example_project( def main(): parser = argparse.ArgumentParser(description="Filter and sort projects.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/initialize_server.py b/samples/initialize_server.py index e7ed0139f..cb3d9e1d0 100644 --- a/samples/initialize_server.py +++ b/samples/initialize_server.py @@ -13,14 +13,10 @@ def main(): parser = argparse.ArgumentParser(description="Initialize a server with content.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", @@ -29,8 +25,8 @@ def main(): help="desired logging level (set to error by default)", ) # Options specific to this sample - parser.add_argument("--datasources-folder", "-df", required=True, help="folder containing datasources") - parser.add_argument("--workbooks-folder", "-wf", required=True, help="folder containing workbooks") + parser.add_argument("--datasources-folder", "-df", help="folder containing datasources") + parser.add_argument("--workbooks-folder", "-wf", help="folder containing workbooks") parser.add_argument("--project", required=False, default="Default", help="project to use") args = parser.parse_args() diff --git a/samples/kill_all_jobs.py b/samples/kill_all_jobs.py index 1a833f938..bfebb49b8 100644 --- a/samples/kill_all_jobs.py +++ b/samples/kill_all_jobs.py @@ -13,14 +13,10 @@ def main(): parser = argparse.ArgumentParser(description="Cancel all of the running background jobs.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/list.py b/samples/list.py index b5cdb38a5..8d72fb620 100644 --- a/samples/list.py +++ b/samples/list.py @@ -15,14 +15,10 @@ def main(): parser = argparse.ArgumentParser(description="List out the names and LUIDs for different resource types.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-n", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-n", 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", diff --git a/samples/login.py b/samples/login.py index f3e9d77dc..6a3e9e8b3 100644 --- a/samples/login.py +++ b/samples/login.py @@ -9,6 +9,7 @@ import logging import tableauserverclient as TSC +import env # If a sample has additional arguments, then it should copy this code and insert them after the call to @@ -18,10 +19,15 @@ def set_up_and_log_in(): parser = argparse.ArgumentParser(description="Logs in to the server.") sample_define_common_options(parser) 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) + if not args.server: + args.server = env.server + if not args.site: + args.site = env.site + if not args.token_name: + args.token_name = env.token_name + if not args.token_value: + args.token_value = env.token_value + args.logging_level = "debug" server = sample_connect_to_server(args) print(server.server_info.get()) @@ -30,9 +36,9 @@ def set_up_and_log_in(): def sample_define_common_options(parser): # Common options; please keep these in sync across all samples by copying or calling this method directly - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-t", help="site name") - auth = parser.add_mutually_exclusive_group(required=True) + auth = parser.add_mutually_exclusive_group(required=False) auth.add_argument("--token-name", "-tn", help="name of the personal access token used to sign into the server") auth.add_argument("--username", "-u", help="username to sign into the server") @@ -73,6 +79,9 @@ def sample_connect_to_server(args): # Make sure we use an updated version of the rest apis, and pass in our cert handling choice server = TSC.Server(args.server, use_server_version=True, http_options={"verify": check_ssl_certificate}) server.auth.sign_in(tableau_auth) + server.version = "2.6" + new_site: TSC.SiteItem = TSC.SiteItem("cdnear", content_url=env.site) + server.auth.switch_site(new_site) print("Logged in successfully") return server diff --git a/samples/metadata_query.py b/samples/metadata_query.py index 26f8f94fa..7524453c2 100644 --- a/samples/metadata_query.py +++ b/samples/metadata_query.py @@ -14,14 +14,10 @@ def main(): parser = argparse.ArgumentParser(description="Use the metadata API to get information on a published data source.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-n", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-n", 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", diff --git a/samples/move_workbook_projects.py b/samples/move_workbook_projects.py index be49ec23b..392dc0ff8 100644 --- a/samples/move_workbook_projects.py +++ b/samples/move_workbook_projects.py @@ -17,14 +17,10 @@ def main(): parser = argparse.ArgumentParser(description="Move one workbook from the default project to another.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", @@ -33,8 +29,8 @@ def main(): help="desired logging level (set to error by default)", ) # Options specific to this sample - parser.add_argument("--workbook-name", "-w", required=True, help="name of workbook to move") - parser.add_argument("--destination-project", "-d", required=True, help="name of project to move workbook into") + parser.add_argument("--workbook-name", "-w", help="name of workbook to move") + parser.add_argument("--destination-project", "-d", help="name of project to move workbook into") args = parser.parse_args() diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index 3feb62be2..47af1f2f9 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -22,14 +22,10 @@ def main(): "the default project of another site." ) # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", @@ -38,8 +34,8 @@ def main(): help="desired logging level (set to error by default)", ) # Options specific to this sample - parser.add_argument("--workbook-name", "-w", required=True, help="name of workbook to move") - parser.add_argument("--destination-site", "-d", required=True, help="name of site to move workbook into") + parser.add_argument("--workbook-name", "-w", help="name of workbook to move") + parser.add_argument("--destination-site", "-d", help="name of site to move workbook into") args = parser.parse_args() diff --git a/samples/pagination_sample.py b/samples/pagination_sample.py index b55fef320..a7ae6dc89 100644 --- a/samples/pagination_sample.py +++ b/samples/pagination_sample.py @@ -20,14 +20,10 @@ def main(): parser = argparse.ArgumentParser(description="Demonstrate pagination on the list of workbooks on the server.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-n", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-n", 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", diff --git a/samples/publish_datasource.py b/samples/publish_datasource.py index 8d9e59ea2..5ac768674 100644 --- a/samples/publish_datasource.py +++ b/samples/publish_datasource.py @@ -23,18 +23,17 @@ import tableauserverclient as TSC +import env +import tableauserverclient.datetime_helpers + def main(): parser = argparse.ArgumentParser(description="Publish a datasource to server.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", @@ -43,7 +42,7 @@ def main(): help="desired logging level (set to error by default)", ) # Options specific to this sample - parser.add_argument("--file", "-f", required=True, help="filepath to the datasource to publish") + parser.add_argument("--file", "-f", help="filepath to the datasource to publish") parser.add_argument("--project", help="Project within which to publish the datasource") parser.add_argument("--async", "-a", help="Publishing asynchronously", dest="async_", action="store_true") parser.add_argument("--conn-username", help="connection username") @@ -52,14 +51,27 @@ def main(): parser.add_argument("--conn-oauth", help="connection is configured to use oAuth", action="store_true") args = parser.parse_args() + if not args.server: + args.server = env.server + if not args.site: + args.site = env.site + if not args.token_name: + args.token_name = env.token_name + if not args.token_value: + args.token_value = env.token_value + args.logging = "debug" + args.file = "C:/dev/tab-samples/5M.tdsx" + args.async_ = True # Ensure that both the connection username and password are provided, or none at all if (args.conn_username and not args.conn_password) or (not args.conn_username and args.conn_password): parser.error("Both the connection username and password must be provided") # Set logging level based on user input, or error by default - logging_level = getattr(logging, args.logging_level.upper()) - logging.basicConfig(level=logging_level) + + _logger = logging.getLogger(__name__) + _logger.setLevel(logging.DEBUG) + _logger.addHandler(logging.StreamHandler()) # Sign in to server tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) @@ -94,6 +106,7 @@ def main(): # Publish datasource if args.async_: + print("Publish as a job") # Async publishing, returns a job_item new_job = server.datasources.publish( new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds, as_job=True @@ -104,7 +117,12 @@ def main(): new_datasource = server.datasources.publish( new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds ) - print("Datasource published. Datasource ID: {0}".format(new_datasource.id)) + print( + "{0}Datasource published. Datasource ID: {1}".format( + new_datasource.id, tableauserverclient.datetime_helpers.timestamp() + ) + ) + print("\t\tClosing connection") if __name__ == "__main__": diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index f0edc380c..8a9f45279 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -24,14 +24,10 @@ def main(): parser = argparse.ArgumentParser(description="Publish a workbook to server.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", @@ -40,7 +36,7 @@ def main(): help="desired logging level (set to error by default)", ) # Options specific to this sample - parser.add_argument("--file", "-f", required=True, help="local filepath of the workbook to publish") + parser.add_argument("--file", "-f", help="local filepath of the workbook to publish") parser.add_argument("--as-job", "-a", help="Publishing asynchronously", action="store_true") parser.add_argument("--skip-connection-check", "-c", help="Skip live connection check", action="store_true") diff --git a/samples/query_permissions.py b/samples/query_permissions.py index 7106da934..4e509cd97 100644 --- a/samples/query_permissions.py +++ b/samples/query_permissions.py @@ -15,14 +15,10 @@ def main(): parser = argparse.ArgumentParser(description="Query permissions of a given resource.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/refresh.py b/samples/refresh.py index f90441224..d3e49ed24 100644 --- a/samples/refresh.py +++ b/samples/refresh.py @@ -13,14 +13,10 @@ def main(): parser = argparse.ArgumentParser(description="Trigger a refresh task on a workbook or datasource.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/refresh_tasks.py b/samples/refresh_tasks.py index 2bfc85621..03daedf16 100644 --- a/samples/refresh_tasks.py +++ b/samples/refresh_tasks.py @@ -30,14 +30,10 @@ def handle_info(server, args): def main(): parser = argparse.ArgumentParser(description="Get all of the refresh tasks available on a server") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/set_refresh_schedule.py b/samples/set_refresh_schedule.py index 9b3dbc236..56fd12e62 100644 --- a/samples/set_refresh_schedule.py +++ b/samples/set_refresh_schedule.py @@ -15,14 +15,10 @@ def usage(args): parser = argparse.ArgumentParser(description="Set refresh schedule for a workbook or datasource.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/smoke_test.py b/samples/smoke_test.py index f2dad1048..b23eacdb8 100644 --- a/samples/smoke_test.py +++ b/samples/smoke_test.py @@ -1,8 +1,16 @@ # This sample verifies that tableau server client is installed # and you can run it. It also shows the version of the client. +import logging import tableauserverclient as TSC + +logger = logging.getLogger("Sample") +logger.setLevel(logging.DEBUG) +logger.addHandler(logging.StreamHandler()) + + server = TSC.Server("Fake-Server-Url", use_server_version=False) print("Client details:") -print(TSC.server.endpoint.Endpoint._make_common_headers("fake-token", "any-content")) +logger.info(server.server_address) +logger.debug(TSC.server.endpoint.Endpoint.set_user_agent({})) diff --git a/samples/update_connection.py b/samples/update_connection.py index e27b4477f..4af6592bc 100644 --- a/samples/update_connection.py +++ b/samples/update_connection.py @@ -13,14 +13,10 @@ def main(): parser = argparse.ArgumentParser(description="Update a connection on a datasource or workbook to embed credentials") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/update_datasource_data.py b/samples/update_datasource_data.py index 41f42ee74..f6bc92022 100644 --- a/samples/update_datasource_data.py +++ b/samples/update_datasource_data.py @@ -25,14 +25,10 @@ def main(): description="Delete the `Europe` region from a published `World Indicators` datasource." ) # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/tableauserverclient/config.py b/tableauserverclient/config.py new file mode 100644 index 000000000..67a77f479 --- /dev/null +++ b/tableauserverclient/config.py @@ -0,0 +1,13 @@ +# TODO: check for env variables, else set default values + +ALLOWED_FILE_EXTENSIONS = ["tds", "tdsx", "tde", "hyper", "parquet"] + +BYTES_PER_MB = 1024 * 1024 + +# For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks +CHUNK_SIZE_MB = 5 * 10 # 5MB felt too slow, upped it to 50 + +DELAY_SLEEP_SECONDS = 10 + +# The maximum size of a file that can be published in a single request is 64MB +FILESIZE_LIMIT_MB = 64 diff --git a/tableauserverclient/datetime_helpers.py b/tableauserverclient/datetime_helpers.py index 0d968428d..00f62faf8 100644 --- a/tableauserverclient/datetime_helpers.py +++ b/tableauserverclient/datetime_helpers.py @@ -5,6 +5,10 @@ HOUR = datetime.timedelta(hours=1) +def timestamp(): + return datetime.datetime.now().strftime("%H:%M:%S") + + # This class is a concrete implementation of the abstract base class tzinfo # docs: https://docs.python.org/2.3/lib/datetime-tzinfo.html class UTC(datetime.tzinfo): diff --git a/tableauserverclient/helpers/logging.py b/tableauserverclient/helpers/logging.py new file mode 100644 index 000000000..414d85786 --- /dev/null +++ b/tableauserverclient/helpers/logging.py @@ -0,0 +1,6 @@ +import logging + +# 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/connection_item.py b/tableauserverclient/models/connection_item.py index 4ed06b831..29ffd2700 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -5,6 +5,7 @@ from .connection_credentials import ConnectionCredentials from .property_decorators import property_is_boolean +from tableauserverclient.helpers.logging import logger class ConnectionItem(object): @@ -46,7 +47,6 @@ def query_tagging(self) -> Optional[bool]: def query_tagging(self, value: Optional[bool]): # if connection type = hyper, Snowflake, or Teradata, we can't change this value: it is always true if self._connection_type in ["hyper", "snowflake", "teradata"]: - logger = logging.getLogger("tableauserverclient.models.connection_item") logger.debug( "Cannot update value: Query tagging is always enabled for {} connections".format(self._connection_type) ) diff --git a/tableauserverclient/models/favorites_item.py b/tableauserverclient/models/favorites_item.py index afa769fd9..dd9dcfaed 100644 --- a/tableauserverclient/models/favorites_item.py +++ b/tableauserverclient/models/favorites_item.py @@ -8,8 +8,7 @@ from .view_item import ViewItem from .workbook_item import WorkbookItem -logger = logging.getLogger("tableau.models.favorites_item") - +from tableauserverclient.helpers.logging import logger from typing import Dict, List, Union FavoriteType = Dict[ diff --git a/tableauserverclient/models/fileupload_item.py b/tableauserverclient/models/fileupload_item.py index 7848b94cf..e9bdd25b2 100644 --- a/tableauserverclient/models/fileupload_item.py +++ b/tableauserverclient/models/fileupload_item.py @@ -11,8 +11,8 @@ def upload_session_id(self): return self._upload_session_id @property - def file_size(self): - return self._file_size + def file_size(self) -> int: + return int(self._file_size) @classmethod def from_response(cls, resp, ns): diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 3bdc63092..1602b077f 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -9,7 +9,7 @@ from .reference_item import ResourceReference from .user_item import UserItem -logger = logging.getLogger("tableau.models.permissions_item") +from tableauserverclient.helpers.logging import logger class Permission: diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py index 5f9395880..b180665dd 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -3,6 +3,7 @@ import xml from defusedxml.ElementTree import fromstring +from tableauserverclient.helpers.logging import logger class ServerInfoItem(object): @@ -36,7 +37,6 @@ def rest_api_version(self): @classmethod def from_response(cls, resp, ns): - logger = logging.getLogger("TSC.ServerInfo") try: parsed_response = fromstring(resp) except xml.etree.ElementTree.ParseError as error: diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index bcea2604e..5abe19446 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -10,9 +10,7 @@ from .filter import Filter from .sort import Sort -from ..models import * from .endpoint import * from .server import Server from .pager import Pager -from .exceptions import NotSignedInError -from ..helpers import * +from .endpoint.exceptions import NotSignedInError diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index e8e1bc0f9..c018d8334 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -5,11 +5,7 @@ from .databases_endpoint import Databases from .datasources_endpoint import Datasources from .endpoint import Endpoint, QuerysetEndpoint -from .exceptions import ( - ServerResponseError, - MissingRequiredFieldError, - ServerInfoEndpointNotFoundError, -) +from .exceptions import ServerResponseError, MissingRequiredFieldError from .favorites_endpoint import Favorites from .fileuploads_endpoint import Fileuploads from .flow_runs_endpoint import FlowRuns diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 68d75eaa8..6f1ddc35e 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -6,7 +6,7 @@ from .exceptions import ServerResponseError from ..request_factory import RequestFactory -logger = logging.getLogger("tableau.endpoint.auth") +from tableauserverclient.helpers.logging import logger class Auth(Endpoint): diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index 778cafecc..119580609 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -6,7 +6,7 @@ from tableauserverclient.models import CustomViewItem, PaginationItem from tableauserverclient.server import RequestFactory, RequestOptions, ImageRequestOptions -logger = logging.getLogger("tableau.endpoint.custom_views") +from tableauserverclient.helpers.logging import logger """ Get a list of custom views on a site diff --git a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py index 28e5495c5..256a6e766 100644 --- a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py +++ b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py @@ -5,7 +5,7 @@ from .permissions_endpoint import _PermissionsEndpoint from tableauserverclient.models import DataAccelerationReportItem -logger = logging.getLogger("tableau.endpoint.data_acceleration_report") +from tableauserverclient.helpers.logging import logger class DataAccelerationReport(Endpoint): diff --git a/tableauserverclient/server/endpoint/data_alert_endpoint.py b/tableauserverclient/server/endpoint/data_alert_endpoint.py index 5af4e0464..fd02d2e4a 100644 --- a/tableauserverclient/server/endpoint/data_alert_endpoint.py +++ b/tableauserverclient/server/endpoint/data_alert_endpoint.py @@ -5,7 +5,7 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import DataAlertItem, PaginationItem, UserItem -logger = logging.getLogger("tableau.endpoint.dataAlerts") +from tableauserverclient.helpers.logging import logger from typing import List, Optional, TYPE_CHECKING, Tuple, Union diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index 2522ef53e..125996277 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -8,7 +8,7 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import DatabaseItem, TableItem, PaginationItem, Resource -logger = logging.getLogger("tableau.endpoint.databases") +from tableauserverclient.helpers.logging import logger class Databases(Endpoint): diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 0c5b8ba61..c60f8f919 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -1,7 +1,6 @@ import cgi import copy import json -import logging import io import os @@ -20,13 +19,14 @@ from .permissions_endpoint import _PermissionsEndpoint from .resource_tagger import _ResourceTagger -from tableauserverclient.server import RequestFactory, RequestOptions +from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, FILESIZE_LIMIT_MB, BYTES_PER_MB, CHUNK_SIZE_MB from tableauserverclient.filesys_helpers import ( - to_filename, make_download_path, get_file_type, get_file_object_size, + to_filename, ) +from tableauserverclient.helpers.logging import logger from tableauserverclient.models import ( ConnectionCredentials, ConnectionItem, @@ -35,6 +35,7 @@ RevisionItem, PaginationItem, ) +from tableauserverclient.server import RequestFactory, RequestOptions io_types = (io.BytesIO, io.BufferedReader) io_types_r = (io.BytesIO, io.BufferedReader) @@ -44,13 +45,6 @@ FileObject = Union[io.BufferedReader, io.BytesIO] PathOrFile = Union[FilePath, FileObject] -# The maximum size of a file that can be published in a single request is 64MB -FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB - -ALLOWED_FILE_EXTENSIONS = ["tds", "tdsx", "tde", "hyper", "parquet"] - -logger = logging.getLogger("tableau.endpoint.datasources") - FilePath = Union[str, os.PathLike] FileObjectR = Union[io.BufferedReader, io.BytesIO] FileObjectW = Union[io.BufferedWriter, io.BytesIO] @@ -162,12 +156,20 @@ def update(self, datasource_item: DatasourceItem) -> DatasourceItem: # Update datasource connections @api(version="2.3") - def update_connection(self, datasource_item: DatasourceItem, connection_item: ConnectionItem) -> ConnectionItem: + def update_connection( + self, datasource_item: DatasourceItem, connection_item: ConnectionItem + ) -> Optional[ConnectionItem]: url = "{0}/{1}/connections/{2}".format(self.baseurl, datasource_item.id, connection_item.id) update_req = RequestFactory.Connection.update_req(connection_item) server_response = self.put_request(url, update_req) - connection = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] + connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) + if not connections: + return None + + if len(connections) > 1: + logger.debug("Multiple connections returned ({0})".format(len(connections))) + connection = list(filter(lambda x: x.id == connection_item.id, connections))[0] logger.info( "Updated datasource item (ID: {0} & connection item {1}".format(datasource_item.id, connection_item.id) @@ -220,7 +222,7 @@ def publish( filename = os.path.basename(file) file_extension = os.path.splitext(filename)[1][1:] file_size = os.path.getsize(file) - + logger.debug("Publishing file `{}`, size `{}`".format(filename, file_size)) # If name is not defined, grab the name from the file to publish if not datasource_item.name: datasource_item.name = os.path.splitext(filename)[0] @@ -261,8 +263,12 @@ def publish( url += "&{0}=true".format("asJob") # Determine if chunking is required (64MB is the limit for single upload method) - if file_size >= FILESIZE_LIMIT: - logger.info("Publishing {0} to server with chunking method (datasource over 64MB)".format(filename)) + if file_size >= FILESIZE_LIMIT_MB * BYTES_PER_MB: + logger.info( + "Publishing {} to server with chunking method (datasource over {}MB, chunk size {}MB)".format( + filename, FILESIZE_LIMIT_MB, CHUNK_SIZE_MB + ) + ) upload_session_id = self.parent_srv.fileuploads.upload(file) url = "{0}&uploadSessionId={1}".format(url, upload_session_id) xml_request, content_type = RequestFactory.Datasource.publish_req_chunked( diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py index b0d16efaf..19112d713 100644 --- a/tableauserverclient/server/endpoint/default_permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py @@ -10,7 +10,7 @@ from ..server import Server from ..request_options import RequestOptions -logger = logging.getLogger(__name__) +from tableauserverclient.helpers.logging import logger # these are the only two items that can hold default permissions for another type BaseItem = Union[DatabaseItem, ProjectItem] diff --git a/tableauserverclient/server/endpoint/dqw_endpoint.py b/tableauserverclient/server/endpoint/dqw_endpoint.py index 96cb7c5f9..5296523ee 100644 --- a/tableauserverclient/server/endpoint/dqw_endpoint.py +++ b/tableauserverclient/server/endpoint/dqw_endpoint.py @@ -5,7 +5,7 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import DQWItem -logger = logging.getLogger(__name__) +from tableauserverclient.helpers.logging import logger class _DataQualityWarningEndpoint(Endpoint): diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 9c933c9dd..c11a3fb27 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,24 +1,31 @@ +from threading import Thread +from time import sleep +from tableauserverclient import datetime_helpers as datetime + import requests -import logging from packaging.version import Version from functools import wraps from xml.etree.ElementTree import ParseError -from typing import Any, Callable, Dict, Optional, TYPE_CHECKING +from typing import Any, Callable, Dict, Optional, TYPE_CHECKING, Union from .exceptions import ( ServerResponseError, InternalServerError, NonXMLResponseError, - EndpointUnavailableError, + NotSignedInError, ) +from ..exceptions import EndpointUnavailableError + from tableauserverclient.server.query import QuerySet from tableauserverclient import helpers, get_versions +from tableauserverclient.helpers.logging import logger +from tableauserverclient.config import DELAY_SLEEP_SECONDS + if TYPE_CHECKING: from ..server import Server from requests import Response -logger = logging.getLogger("tableau.endpoint") Success_codes = [200, 201, 202, 204] @@ -34,6 +41,8 @@ class Endpoint(object): def __init__(self, parent_srv: "Server"): self.parent_srv = parent_srv + async_response = None + @staticmethod def set_parameters(http_options, auth_token, content, content_type, parameters) -> Dict[str, Any]: parameters = parameters or {} @@ -53,6 +62,8 @@ def set_parameters(http_options, auth_token, content, content_type, parameters) @staticmethod def set_user_agent(parameters): + if "headers" not in parameters: + parameters["headers"] = {} if USER_AGENT_HEADER not in parameters["headers"]: if USER_AGENT_HEADER in parameters: parameters["headers"][USER_AGENT_HEADER] = parameters[USER_AGENT_HEADER] @@ -65,6 +76,59 @@ def set_user_agent(parameters): # return explicitly for testing only return parameters + def _blocking_request(self, method, url, parameters={}) -> Optional["Response"]: + self.async_response = None + response = None + logger.debug("[{}] Begin blocking request to {}".format(datetime.timestamp(), url)) + try: + response = method(url, **parameters) + self.async_response = response + logger.debug("[{}] Call finished".format(datetime.timestamp())) + except Exception as e: + logger.debug("Error making request to server: {}".format(e)) + self.async_response = e + finally: + if response and not self.async_response: + logger.debug("Request response not saved") + return None + logger.debug("[{}] Request complete".format(datetime.timestamp())) + return self.async_response + + def send_request_while_show_progress_threaded( + self, method, url, parameters={}, request_timeout=0 + ) -> Optional["Response"]: + try: + request_thread = Thread(target=self._blocking_request, args=(method, url, parameters)) + request_thread.async_response = -1 # type:ignore # this is an invented attribute for thread comms + request_thread.start() + except Exception as e: + logger.debug("Error starting server request on separate thread: {}".format(e)) + return None + seconds = 0 + minutes = 0 + sleep(1) + if self.async_response != -1: + # a quick return for any immediate responses + return self.async_response + while self.async_response == -1 and (request_timeout == 0 or seconds < request_timeout): + self.log_wait_time_then_sleep(minutes, seconds, url) + seconds = seconds + DELAY_SLEEP_SECONDS + if seconds >= 60: + seconds = 0 + minutes = minutes + 1 + return self.async_response + + def log_wait_time_then_sleep(self, minutes, seconds, url): + logger.debug("{} Waiting....".format(datetime.timestamp())) + if seconds >= 60: # detailed log message ~every minute + if minutes % 5 == 0: + logger.info( + "[{}] Waiting ({} minutes so far) for request to {}".format(datetime.timestamp(), minutes, url) + ) + else: + logger.debug("[{}] Waiting for request to {}".format(datetime.timestamp(), url)) + sleep(DELAY_SLEEP_SECONDS) + def _make_request( self, method: Callable[..., "Response"], @@ -80,36 +144,59 @@ def _make_request( logger.debug("request method {}, url: {}".format(method.__name__, url)) if content: - redacted = helpers.strings.redact_xml(content[:1000]) + redacted = helpers.strings.redact_xml(content[:200]) + # this needs to be under a trace or something, it's a LOT # logger.debug("request content: {}".format(redacted)) - server_response = method(url, **parameters) + # a request can, for stuff like publishing, spin for ages waiting for a response. + # we need some user-facing activity so they know it's not dead. + request_timeout = self.parent_srv.http_options.get("timeout") or 0 + server_response: Optional["Response"] = self.send_request_while_show_progress_threaded( + method, url, parameters, request_timeout + ) + logger.debug("[{}] Async request returned: received {}".format(datetime.timestamp(), server_response)) + # is this blocking retry really necessary? I guess if it was just the threading messing it up? + if server_response is None: + logger.debug(server_response) + logger.debug("[{}] Async request failed: retrying".format(datetime.timestamp())) + server_response = self._blocking_request(method, url, parameters) + if server_response is None: + logger.debug("[{}] Request failed".format(datetime.timestamp())) + raise RuntimeError self._check_status(server_response, url) loggable_response = self.log_response_safely(server_response) - # logger.debug("Server response from {0}:\n\t{1}".format(url, loggable_response)) + logger.debug("Server response from {0}".format(url)) + # logger.debug("\n\t{1}".format(loggable_response)) if content_type == "application/xml": self.parent_srv._namespace.detect(server_response.content) return server_response - def _check_status(self, server_response, url: Optional[str] = None): + def _check_status(self, server_response: "Response", url: Optional[str] = None): + logger.debug("Response status: {}".format(server_response)) + if not hasattr(server_response, "status_code"): + raise EnvironmentError("Response is not a http response?") if server_response.status_code >= 500: raise InternalServerError(server_response, url) elif server_response.status_code not in Success_codes: try: + if server_response.status_code == 401: + # TODO: catch this in server.py and attempt to sign in again, in case it's a session expiry + raise NotSignedInError(server_response.content, url) + raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace, url) except ParseError: # This will happen if we get a non-success HTTP code that doesn't return an xml error object - # e.g metadata endpoints, 503 pages, totally different servers + # e.g. metadata endpoints, 503 pages, totally different servers # we convert this to a better exception and pass through the raw response body raise NonXMLResponseError(server_response.content) except Exception: # anything else re-raise here raise - def log_response_safely(self, server_response: requests.Response) -> str: + def log_response_safely(self, server_response: "Response") -> str: # Checking the content type header prevents eager evaluation of streaming requests. content_type = server_response.headers.get("Content-Type") @@ -117,7 +204,7 @@ def log_response_safely(self, server_response: requests.Response) -> str: # content-type is an octet-stream accomplishes the same goal without eagerly loading content. # This check is to determine if the response is a text response (xml or otherwise) # so that we do not attempt to log bytes and other binary data. - loggable_response = "Content type {}".format(content_type) + loggable_response = "Content type `{}`".format(content_type) if content_type == "application/octet-stream": loggable_response = "A stream of type {} [Truncated File Contents]".format(content_type) elif server_response.encoding and len(server_response.content) > 0: diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index d7b1d5ad2..9dfd38da6 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -47,11 +47,7 @@ class MissingRequiredFieldError(TableauError): pass -class ServerInfoEndpointNotFoundError(TableauError): - pass - - -class EndpointUnavailableError(TableauError): +class NotSignedInError(TableauError): pass diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index 5105b3bf4..81bb468f8 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -10,7 +10,7 @@ from ...models import DatasourceItem, FlowItem, ProjectItem, UserItem, ViewItem, WorkbookItem from ..request_options import RequestOptions -logger = logging.getLogger("tableau.endpoint.favorites") +from tableauserverclient.helpers.logging import logger class Favorites(Endpoint): diff --git a/tableauserverclient/server/endpoint/fileuploads_endpoint.py b/tableauserverclient/server/endpoint/fileuploads_endpoint.py index 9a8e9560d..a0e29e508 100644 --- a/tableauserverclient/server/endpoint/fileuploads_endpoint.py +++ b/tableauserverclient/server/endpoint/fileuploads_endpoint.py @@ -1,13 +1,10 @@ -import logging - from .endpoint import Endpoint, api -from tableauserverclient.server import RequestFactory -from tableauserverclient.models import FileuploadItem +from tableauserverclient import datetime_helpers as datetime +from tableauserverclient.helpers.logging import logger -# For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks -CHUNK_SIZE = 1024 * 1024 * 5 # 5MB - -logger = logging.getLogger("tableau.endpoint.fileuploads") +from tableauserverclient.config import BYTES_PER_MB, CHUNK_SIZE_MB +from tableauserverclient.models import FileuploadItem +from tableauserverclient.server import RequestFactory class Fileuploads(Endpoint): @@ -44,7 +41,7 @@ def _read_chunks(self, file): try: while True: - chunked_content = file_content.read(CHUNK_SIZE) + chunked_content = file_content.read(CHUNK_SIZE_MB * BYTES_PER_MB) if not chunked_content: break yield chunked_content @@ -55,8 +52,12 @@ def _read_chunks(self, file): def upload(self, file): upload_id = self.initiate() for chunk in self._read_chunks(file): + logger.debug("{} processing chunk...".format(datetime.timestamp())) request, content_type = RequestFactory.Fileupload.chunk_req(chunk) + logger.debug("{} created chunk request".format(datetime.timestamp())) fileupload_item = self.append(upload_id, request, content_type) - logger.info("\tPublished {0}MB".format(fileupload_item.file_size)) + logger.info( + "\t{0} Published {1}MB".format(datetime.timestamp(), (fileupload_item.file_size / BYTES_PER_MB)) + ) logger.info("File upload finished (ID: {0})".format(upload_id)) return upload_id diff --git a/tableauserverclient/server/endpoint/flow_runs_endpoint.py b/tableauserverclient/server/endpoint/flow_runs_endpoint.py index 3bca93a7f..63b32e006 100644 --- a/tableauserverclient/server/endpoint/flow_runs_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_runs_endpoint.py @@ -6,7 +6,7 @@ from tableauserverclient.models import FlowRunItem, PaginationItem from tableauserverclient.exponential_backoff import ExponentialBackoffTimer -logger = logging.getLogger("tableau.endpoint.flowruns") +from tableauserverclient.helpers.logging import logger if TYPE_CHECKING: from ..server import Server diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 4d97110c4..ba8a152d7 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -32,13 +32,13 @@ ALLOWED_FILE_EXTENSIONS = ["tfl", "tflx"] -logger = logging.getLogger("tableau.endpoint.flows") +from tableauserverclient.helpers.logging import logger if TYPE_CHECKING: - from .. import DQWItem - from ..request_options import RequestOptions - from ...models.permissions_item import PermissionsRule - from .schedules_endpoint import AddResponse + from tableauserverclient.models import DQWItem + from tableauserverclient.models.permissions_item import PermissionsRule + from tableauserverclient.server.request_options import RequestOptions + from tableauserverclient.server.endpoint.schedules_endpoint import AddResponse FilePath = Union[str, os.PathLike] diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index ba5b6649b..ad3828568 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -6,7 +6,7 @@ from tableauserverclient.models import GroupItem, UserItem, PaginationItem, JobItem from ..pager import Pager -logger = logging.getLogger("tableau.endpoint.groups") +from tableauserverclient.helpers.logging import logger from typing import List, Optional, TYPE_CHECKING, Tuple, Union diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index dd210d990..d0b865e21 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -6,7 +6,7 @@ from ..request_options import RequestOptionsBase from tableauserverclient.exponential_backoff import ExponentialBackoffTimer -logger = logging.getLogger("tableau.endpoint.jobs") +from tableauserverclient.helpers.logging import logger from typing import List, Optional, Tuple, Union diff --git a/tableauserverclient/server/endpoint/metadata_endpoint.py b/tableauserverclient/server/endpoint/metadata_endpoint.py index 06339fa79..39146d062 100644 --- a/tableauserverclient/server/endpoint/metadata_endpoint.py +++ b/tableauserverclient/server/endpoint/metadata_endpoint.py @@ -4,7 +4,7 @@ from .endpoint import Endpoint, api from .exceptions import GraphQLError, InvalidGraphQLQuery -logger = logging.getLogger("tableau.endpoint.metadata") +from tableauserverclient.helpers.logging import logger def is_valid_paged_query(parsed_query): diff --git a/tableauserverclient/server/endpoint/metrics_endpoint.py b/tableauserverclient/server/endpoint/metrics_endpoint.py index 8443726cd..a0e984475 100644 --- a/tableauserverclient/server/endpoint/metrics_endpoint.py +++ b/tableauserverclient/server/endpoint/metrics_endpoint.py @@ -15,7 +15,7 @@ from ...server import Server -logger = logging.getLogger("tableau.endpoint.metrics") +from tableauserverclient.helpers.logging import logger class Metrics(QuerysetEndpoint): diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py index e50e32945..4433625f2 100644 --- a/tableauserverclient/server/endpoint/permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/permissions_endpoint.py @@ -8,7 +8,7 @@ from typing import Callable, TYPE_CHECKING, List, Optional, Union -logger = logging.getLogger(__name__) +from tableauserverclient.helpers.logging import logger if TYPE_CHECKING: from ..server import Server diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 440940606..510f1ff3d 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -13,7 +13,7 @@ from ..server import Server from ..request_options import RequestOptions -logger = logging.getLogger("tableau.endpoint.projects") +from tableauserverclient.helpers.logging import logger class Projects(QuerysetEndpoint): diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index 18c38798e..8177bd733 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -1,13 +1,13 @@ import copy -import logging import urllib.parse from .endpoint import Endpoint -from .exceptions import EndpointUnavailableError, ServerResponseError +from .exceptions import ServerResponseError +from ..exceptions import EndpointUnavailableError from tableauserverclient.server import RequestFactory from tableauserverclient.models import TagItem -logger = logging.getLogger("tableau.endpoint.resource_tagger") +from tableauserverclient.helpers.logging import logger class _ResourceTagger(Endpoint): diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index 7cca1f5d5..cfaee3324 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -9,7 +9,8 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import PaginationItem, ScheduleItem, TaskItem -logger = logging.getLogger("tableau.endpoint.schedules") +from tableauserverclient.helpers.logging import logger + AddResponse = namedtuple("AddResponse", ("result", "error", "warnings", "task_created")) OK = AddResponse(result=True, error=None, warnings=None, task_created=None) diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index b396a1f87..26aaf2910 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -1,15 +1,13 @@ import logging from .endpoint import Endpoint, api -from .exceptions import ( - ServerResponseError, +from .exceptions import ServerResponseError +from ..exceptions import ( ServerInfoEndpointNotFoundError, EndpointUnavailableError, ) from tableauserverclient.models import ServerInfoItem -logger = logging.getLogger("tableau.endpoint.server_info") - class ServerInfo(Endpoint): def __init__(self, server): diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index a4c765484..dfec49ae1 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -6,7 +6,7 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import SiteItem, PaginationItem -logger = logging.getLogger("tableau.endpoint.sites") +from tableauserverclient.helpers.logging import logger from typing import TYPE_CHECKING, List, Optional, Tuple diff --git a/tableauserverclient/server/endpoint/subscriptions_endpoint.py b/tableauserverclient/server/endpoint/subscriptions_endpoint.py index a81a2fbf0..a9f2e7bf5 100644 --- a/tableauserverclient/server/endpoint/subscriptions_endpoint.py +++ b/tableauserverclient/server/endpoint/subscriptions_endpoint.py @@ -5,7 +5,7 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import SubscriptionItem, PaginationItem -logger = logging.getLogger("tableau.endpoint.subscriptions") +from tableauserverclient.helpers.logging import logger from typing import List, Optional, TYPE_CHECKING, Tuple diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index e51f885d7..dfb2e6d7c 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -8,7 +8,7 @@ from tableauserverclient.models import TableItem, ColumnItem, PaginationItem from ..pager import Pager -logger = logging.getLogger("tableau.endpoint.tables") +from tableauserverclient.helpers.logging import logger class Tables(Endpoint): diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index b903ac634..ad1702f58 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -5,7 +5,7 @@ from tableauserverclient.models import TaskItem, PaginationItem from tableauserverclient.server import RequestFactory -logger = logging.getLogger("tableau.endpoint.tasks") +from tableauserverclient.helpers.logging import logger class Tasks(Endpoint): diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 5a9c74619..e8c5cc962 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -8,7 +8,7 @@ from tableauserverclient.models import UserItem, WorkbookItem, PaginationItem, GroupItem from ..pager import Pager -logger = logging.getLogger("tableau.endpoint.users") +from tableauserverclient.helpers.logging import logger class Users(QuerysetEndpoint): diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index c060298ba..9c4b90657 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -7,7 +7,7 @@ from .resource_tagger import _ResourceTagger from tableauserverclient.models import ViewItem, PaginationItem -logger = logging.getLogger("tableau.endpoint.views") +from tableauserverclient.helpers.logging import logger from typing import Iterator, List, Optional, Tuple, TYPE_CHECKING diff --git a/tableauserverclient/server/endpoint/webhooks_endpoint.py b/tableauserverclient/server/endpoint/webhooks_endpoint.py index 69a958988..597f9c425 100644 --- a/tableauserverclient/server/endpoint/webhooks_endpoint.py +++ b/tableauserverclient/server/endpoint/webhooks_endpoint.py @@ -4,7 +4,7 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import WebhookItem, PaginationItem -logger = logging.getLogger("tableau.endpoint.webhooks") +from tableauserverclient.helpers.logging import logger from typing import List, Optional, TYPE_CHECKING, Tuple diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 295a4941f..dc4adafaa 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -44,7 +44,8 @@ ALLOWED_FILE_EXTENSIONS = ["twb", "twbx"] -logger = logging.getLogger("tableau.endpoint.workbooks") +from tableauserverclient.helpers.logging import logger + FilePath = Union[str, os.PathLike] FileObject = Union[io.BufferedReader, io.BytesIO] FileObjectR = Union[io.BufferedReader, io.BytesIO] diff --git a/tableauserverclient/server/exceptions.py b/tableauserverclient/server/exceptions.py index 09d3d0541..6c9bbcefc 100644 --- a/tableauserverclient/server/exceptions.py +++ b/tableauserverclient/server/exceptions.py @@ -1,2 +1,9 @@ -class NotSignedInError(Exception): +# These errors can be thrown without even talking to Tableau Server + + +class ServerInfoEndpointNotFoundError(Exception): + pass + + +class EndpointUnavailableError(Exception): pass diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index baedd74de..fa0a2d68a 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,7 +1,7 @@ from tableauserverclient.models.property_decorators import property_is_int import logging -logger = logging.getLogger("tableau.request_options") +from tableauserverclient.helpers.logging import logger class RequestOptionsBase(object): diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 887b9de6d..ee23789b1 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -1,4 +1,4 @@ -import logging +from tableauserverclient.helpers.logging import logger import requests import urllib3 @@ -34,11 +34,11 @@ Metrics, Endpoint, ) -from .endpoint.exceptions import ( +from .exceptions import ( ServerInfoEndpointNotFoundError, EndpointUnavailableError, ) -from .exceptions import NotSignedInError +from .endpoint.exceptions import NotSignedInError from ..namespace import Namespace @@ -99,8 +99,6 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self.metrics = Metrics(self) self.custom_views = CustomViews(self) - self.logger = logging.getLogger("TSC.server") - self._session = self._session_factory() self._http_options = dict() # must set this before making a server call if http_options: @@ -114,7 +112,8 @@ def __init__(self, server_address, use_server_version=False, http_options=None, def validate_connection_settings(self): try: - Endpoint(self).set_parameters(self._http_options, None, None, None, None) + params = Endpoint(self).set_parameters(self._http_options, None, None, None, None) + Endpoint.set_user_agent(params) if not self._server_address.startswith("http://") and not self._server_address.startswith("https://"): self._server_address = "http://" + self._server_address self._session.prepare_request(requests.Request("GET", url=self._server_address, params=self._http_options)) @@ -156,8 +155,8 @@ def _get_legacy_version(self): try: info_xml = fromstring(response.content) except ParseError as parseError: - self.logger.info(parseError) - self.logger.info("Could not read server version info. The server may not be running or configured.") + logger.info(parseError) + logger.info("Could not read server version info. The server may not be running or configured.") return self.version prod_version = info_xml.find(".//product_version").text version = _PRODUCT_TO_REST_VERSION.get(prod_version, minimum_supported_server_version) @@ -168,15 +167,15 @@ def _determine_highest_version(self): old_version = self.version version = self.server_info.get().rest_api_version except ServerInfoEndpointNotFoundError as e: - self.logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) + logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) version = self._get_legacy_version() except EndpointUnavailableError as e: - self.logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) + logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) version = self._get_legacy_version() except Exception as e: - self.logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) + logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) version = None - self.logger.info("versions: {}, {}".format(version, old_version)) + logger.info("versions: {}, {}".format(version, old_version)) return version or old_version def use_server_version(self): @@ -184,7 +183,7 @@ def use_server_version(self): def use_highest_version(self): self.use_server_version() - self.logger.info("use use_server_version instead", DeprecationWarning) + logger.info("use use_server_version instead", DeprecationWarning) def check_at_least_version(self, target: str): server_version = Version(self.version or "2.4") diff --git a/test/test_auth.py b/test/test_auth.py index 40255f627..eaf13481e 100644 --- a/test/test_auth.py +++ b/test/test_auth.py @@ -63,7 +63,7 @@ def test_sign_in_error(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.TableauAuth("testuser", "wrongpassword") - self.assertRaises(TSC.ServerResponseError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) def test_sign_in_invalid_token(self): with open(SIGN_IN_ERROR_XML, "rb") as f: @@ -71,7 +71,7 @@ def test_sign_in_invalid_token(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.PersonalAccessTokenAuth(token_name="mytoken", personal_access_token="invalid") - self.assertRaises(TSC.ServerResponseError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) def test_sign_in_without_auth(self): with open(SIGN_IN_ERROR_XML, "rb") as f: @@ -79,7 +79,7 @@ def test_sign_in_without_auth(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.TableauAuth("", "") - self.assertRaises(TSC.ServerResponseError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) def test_sign_out(self): with open(SIGN_IN_XML, "rb") as f: diff --git a/test/test_datasource.py b/test/test_datasource.py index 4f3529762..730e382da 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -145,9 +145,9 @@ def test_update_copy_fields(self) -> None: def test_update_tags(self) -> None: add_tags_xml, update_xml = read_xml_assets(ADD_TAGS_XML, UPDATE_XML) with requests_mock.mock() as m: - m.put(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags", text=add_tags_xml) m.delete(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags/b", status_code=204) m.delete(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags/d", status_code=204) + m.put(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags", text=add_tags_xml) m.put(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", text=update_xml) single_datasource = TSC.DatasourceItem("1d0304cd-3796-429f-b815-7258370b9b74") single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" @@ -191,7 +191,7 @@ def test_update_connection(self) -> None: self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections/be786ae0-d2bf-4a4b-9b34-e2de8d2d4488", text=response_xml, ) - single_datasource = TSC.DatasourceItem("1d0304cd-3796-429f-b815-7258370b9b74") + single_datasource = TSC.DatasourceItem("be786ae0-d2bf-4a4b-9b34-e2de8d2d4488") single_datasource.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" self.server.datasources.populate_connections(single_datasource) @@ -610,7 +610,7 @@ def test_synchronous_publish_timeout_error(self) -> None: new_datasource = TSC.DatasourceItem(project_id="") publish_mode = self.server.PublishMode.CreateNew - + # http://test/api/2.4/sites/dad65087-b08b-4603-af4e-2887b8aafc67/datasources?datasourceType=tds self.assertRaisesRegex( InternalServerError, "Please use asynchronous publishing to avoid timeouts.", diff --git a/test/test_endpoint.py b/test/test_endpoint.py index 0d8ae84f2..3d2d1c995 100644 --- a/test/test_endpoint.py +++ b/test/test_endpoint.py @@ -15,9 +15,32 @@ def setUp(self) -> None: # Fake signin self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - return super().setUp() + def test_fallback_request_logic(self) -> None: + url = "http://test/" + endpoint = TSC.server.Endpoint(self.server) + with requests_mock.mock() as m: + m.get(url) + response = endpoint.get_request(url=url) + self.assertIsNotNone(response) + + def test_user_friendly_request_returns(self) -> None: + url = "http://test/" + endpoint = TSC.server.Endpoint(self.server) + with requests_mock.mock() as m: + m.get(url) + response = endpoint.send_request_while_show_progress_threaded( + endpoint.parent_srv.session.get, url=url, request_timeout=2 + ) + self.assertIsNotNone(response) + + def test_blocking_request_returns(self) -> None: + url = "http://test/" + endpoint = TSC.server.Endpoint(self.server) + response = endpoint._blocking_request(endpoint.parent_srv.session.get, url=url) + self.assertIsNotNone(response) + def test_get_request_stream(self) -> None: url = "http://test/" endpoint = TSC.server.Endpoint(self.server) diff --git a/test/test_fileuploads.py b/test/test_fileuploads.py index 4d3b0c864..cf0861e24 100644 --- a/test/test_fileuploads.py +++ b/test/test_fileuploads.py @@ -43,7 +43,7 @@ def test_upload_chunks_file_path(self): append_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=initialize_response_xml) - m.put(self.baseurl + "/" + upload_id, text=append_response_xml) + m.put("{}/{}".format(self.baseurl, upload_id), text=append_response_xml) actual = self.server.fileuploads.upload(file_path) self.assertEqual(upload_id, actual) @@ -58,7 +58,7 @@ def test_upload_chunks_file_object(self): append_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=initialize_response_xml) - m.put(self.baseurl + "/" + upload_id, text=append_response_xml) + m.put("{}/{}".format(self.baseurl, upload_id), text=append_response_xml) actual = self.server.fileuploads.upload(file_content) self.assertEqual(upload_id, actual) diff --git a/test/test_request_option.py b/test/test_request_option.py index 9dacbe033..5d8bdf05e 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -22,7 +22,7 @@ class RequestOptionTests(unittest.TestCase): def setUp(self) -> None: - self.server = TSC.Server("http://test", False) + self.server = TSC.Server("http://test", False, http_options={"timeout": 5}) # Fake signin self.server.version = "3.10" @@ -151,7 +151,7 @@ def test_multiple_filter_options(self) -> None: ) ) req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, "foo")) - for _ in range(100): + for _ in range(5): matching_workbooks, pagination_item = self.server.workbooks.get(req_option) self.assertEqual(3, pagination_item.total_available) @@ -245,7 +245,7 @@ def test_multiple_filter_options_shorthand(self) -> None: ) m.get(url, text=response_xml) - for _ in range(100): + for _ in range(5): matching_workbooks = self.server.workbooks.filter(tags__in=["sample", "safari", "weather"], name="foo") self.assertEqual(3, matching_workbooks.total_available) diff --git a/test/test_webhook.py b/test/test_webhook.py index ff8b7048e..5f26266b2 100644 --- a/test/test_webhook.py +++ b/test/test_webhook.py @@ -4,7 +4,8 @@ import requests_mock import tableauserverclient as TSC -from tableauserverclient.server import RequestFactory, WebhookItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import WebhookItem from ._utils import asset TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") From cc62a50372910c92e4189e7ac0b48a49395fd128 Mon Sep 17 00:00:00 2001 From: Nicole Arcolino <107720857+narcolino-tableau@users.noreply.github.com> Date: Tue, 18 Apr 2023 20:09:45 -0700 Subject: [PATCH 032/296] adding sample file for favorites, issue #737 (#1085) * adding sample file for favorites, issue #737 * add metrics to favorites Cleaned up the from_response method for several items because we can now initialize them empty Co-authored-by: Jac Fitzgerald Co-authored-by: Nicole Arcolino --- samples/explore_favorites.py | 85 ++++++++++++++++ tableauserverclient/models/datasource_item.py | 48 ++-------- tableauserverclient/models/favorites_item.py | 94 ++++++++++-------- tableauserverclient/models/flow_item.py | 55 ++++++----- tableauserverclient/models/metric_item.py | 60 ++++++------ tableauserverclient/models/project_item.py | 22 ++--- tableauserverclient/models/tableau_types.py | 6 +- tableauserverclient/models/user_item.py | 1 + tableauserverclient/models/view_item.py | 70 +++++++------- tableauserverclient/models/workbook_item.py | 52 ++-------- .../server/endpoint/favorites_endpoint.py | 96 ++++++++++++++----- tableauserverclient/server/request_factory.py | 19 ++-- test/test_favorites.py | 2 + test/test_project.py | 2 +- test/test_project_model.py | 12 +-- 15 files changed, 358 insertions(+), 266 deletions(-) create mode 100644 samples/explore_favorites.py diff --git a/samples/explore_favorites.py b/samples/explore_favorites.py new file mode 100644 index 000000000..243e91954 --- /dev/null +++ b/samples/explore_favorites.py @@ -0,0 +1,85 @@ +# This script demonstrates how to get all favorites, or add/delete a favorite. + +import argparse +import logging +import tableauserverclient as TSC +from tableauserverclient import Resource + + +def main(): + parser = argparse.ArgumentParser(description="Explore favoriting functions supported by the Server API.") + # 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)", + ) + + 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) + + # SIGN IN + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=True) + with server.auth.sign_in(tableau_auth): + print(server) + my_workbook = None + my_view = None + my_datasource = None + + # get all favorites on site for the logged on user + user: TSC.UserItem = TSC.UserItem() + user.id = server.user_id + print("Favorites for user: {}".format(user.id)) + server.favorites.get(user) + print(user.favorites) + + # get list of workbooks + all_workbook_items, pagination_item = server.workbooks.get() + if all_workbook_items is not None and len(all_workbook_items) > 0: + my_workbook: TSC.WorkbookItem = all_workbook_items[0] + server.favorites.add_favorite(server, user, Resource.Workbook.name(), all_workbook_items[0]) + print( + "Workbook added to favorites. Workbook Name: {}, Workbook ID: {}".format( + my_workbook.name, my_workbook.id + ) + ) + views = server.workbooks.populate_views(my_workbook) + if views is not None and len(views) > 0: + my_view = views[0] + server.favorites.add_favorite_view(user, my_view) + print("View added to favorites. View Name: {}, View ID: {}".format(my_view.name, my_view.id)) + + all_datasource_items, pagination_item = server.datasources.get() + if all_datasource_items: + my_datasource = all_datasource_items[0] + server.favorites.add_favorite_datasource(user, my_datasource) + print( + "Datasource added to favorites. Datasource Name: {}, Datasource ID: {}".format( + my_datasource.name, my_datasource.id + ) + ) + + server.favorites.delete_favorite_workbook(user, my_workbook) + print( + "Workbook deleted from favorites. Workbook Name: {}, Workbook ID: {}".format(my_workbook.name, my_workbook.id) + ) + + server.favorites.delete_favorite_view(user, my_view) + print("View deleted from favorites. View Name: {}, View ID: {}".format(my_view.name, my_view.id)) + + server.favorites.delete_favorite_datasource(user, my_datasource) + print( + "Datasource deleted from favorites. Datasource Name: {}, Datasource ID: {}".format( + my_datasource.name, my_datasource.id + ) + ) diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index dbaa0ff91..7fcc31ebf 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -305,50 +305,16 @@ def from_response(cls, resp: str, ns: Dict) -> List["DatasourceItem"]: all_datasource_xml = parsed_response.findall(".//t:datasource", namespaces=ns) for datasource_xml in all_datasource_xml: - ( - ask_data_enablement, - certified, - certification_note, - content_url, - created_at, - datasource_type, - description, - encrypt_extracts, - has_extracts, - id_, - name, - owner_id, - project_id, - project_name, - tags, - updated_at, - use_remote_query_agent, - webpage_url, - ) = cls._parse_element(datasource_xml, ns) - datasource_item = cls(project_id) - datasource_item._set_values( - ask_data_enablement, - certified, - certification_note, - content_url, - created_at, - datasource_type, - description, - encrypt_extracts, - has_extracts, - id_, - name, - owner_id, - None, - project_name, - tags, - updated_at, - use_remote_query_agent, - webpage_url, - ) + datasource_item = cls.from_xml(datasource_xml, ns) all_datasource_items.append(datasource_item) return all_datasource_items + @classmethod + def from_xml(cls, datasource_xml, ns): + datasource_item = cls() + datasource_item._set_values(*cls._parse_element(datasource_xml, ns)) + return datasource_item + @staticmethod def _parse_element(datasource_xml: ET.Element, ns: Dict) -> Tuple: id_ = datasource_xml.get("id", None) diff --git a/tableauserverclient/models/favorites_item.py b/tableauserverclient/models/favorites_item.py index afa769fd9..f075d1fc3 100644 --- a/tableauserverclient/models/favorites_item.py +++ b/tableauserverclient/models/favorites_item.py @@ -1,74 +1,90 @@ import logging from defusedxml.ElementTree import fromstring +from .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 typing import Dict, List logger = logging.getLogger("tableau.models.favorites_item") -from typing import Dict, List, Union FavoriteType = Dict[ str, - List[ - Union[ - DatasourceItem, - ProjectItem, - FlowItem, - ViewItem, - WorkbookItem, - ] - ], + List[TableauItem], ] class FavoriteItem: - class Type: - Workbook: str = "workbook" - Datasource: str = "datasource" - View: str = "view" - Project: str = "project" - Flow: str = "flow" - @classmethod def from_response(cls, xml: str, namespace: Dict) -> FavoriteType: favorites: FavoriteType = { "datasources": [], "flows": [], "projects": [], + "metrics": [], "views": [], "workbooks": [], } - parsed_response = fromstring(xml) - for workbook in parsed_response.findall(".//t:favorite/t:workbook", namespace): - fav_workbook = WorkbookItem("") - fav_workbook._set_values(*fav_workbook._parse_element(workbook, namespace)) - if fav_workbook: - favorites["workbooks"].append(fav_workbook) - for view in parsed_response.findall(".//t:favorite[t:view]", namespace): - fav_views = ViewItem.from_xml_element(view, namespace) - if fav_views: - for fav_view in fav_views: - favorites["views"].append(fav_view) - for datasource in parsed_response.findall(".//t:favorite/t:datasource", namespace): - fav_datasource = DatasourceItem("") - fav_datasource._set_values(*fav_datasource._parse_element(datasource, namespace)) + + datasources_xml = parsed_response.findall(".//t:favorite/t:datasource", namespace) + flows_xml = parsed_response.findall(".//t:favorite/t:flow", namespace) + metrics_xml = parsed_response.findall(".//t:favorite/t:metric", namespace) + projects_xml = parsed_response.findall(".//t:favorite/t:project", namespace) + views_xml = parsed_response.findall(".//t:favorite/t:view", namespace) + workbooks_xml = parsed_response.findall(".//t:favorite/t:workbook", namespace) + + logger.debug( + "ds: {}, flows: {}, metrics: {}, projects: {}, views: {}, wbs: {}".format( + len(datasources_xml), + len(flows_xml), + len(metrics_xml), + len(projects_xml), + len(views_xml), + len(workbooks_xml), + ) + ) + for datasource in datasources_xml: + fav_datasource = DatasourceItem.from_xml(datasource, namespace) if fav_datasource: + logger.debug(fav_datasource) favorites["datasources"].append(fav_datasource) - for project in parsed_response.findall(".//t:favorite/t:project", namespace): - fav_project = ProjectItem("p") - fav_project._set_values(*fav_project._parse_element(project)) - if fav_project: - favorites["projects"].append(fav_project) - for flow in parsed_response.findall(".//t:favorite/t:flow", namespace): - fav_flow = FlowItem("flows") - fav_flow._set_values(*fav_flow._parse_element(flow, namespace)) + + for flow in flows_xml: + fav_flow = FlowItem.from_xml(flow, namespace) if fav_flow: + logger.debug(fav_flow) favorites["flows"].append(fav_flow) + for metric in metrics_xml: + fav_metric = MetricItem.from_xml(metric, namespace) + if fav_metric: + logger.debug(fav_metric) + favorites["metrics"].append(fav_metric) + + for project in projects_xml: + fav_project = ProjectItem.from_xml(project, namespace) + if fav_project: + logger.debug(fav_project) + favorites["projects"].append(fav_project) + + for view in views_xml: + fav_view = ViewItem.from_xml(view, namespace) + if fav_view: + logger.debug(fav_view) + favorites["views"].append(fav_view) + + for workbook in workbooks_xml: + fav_workbook = WorkbookItem.from_xml(workbook, namespace) + if fav_workbook: + logger.debug(fav_workbook) + favorites["workbooks"].append(fav_workbook) + + logger.debug(favorites) return favorites diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index f48910602..d543ad8eb 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -176,34 +176,39 @@ def from_response(cls, resp, ns) -> List["FlowItem"]: all_flow_xml = parsed_response.findall(".//t:flow", namespaces=ns) for flow_xml in all_flow_xml: - ( - id_, - name, - description, - webpage_url, - created_at, - updated_at, - tags, - project_id, - project_name, - owner_id, - ) = cls._parse_element(flow_xml, ns) - flow_item = cls(project_id) - flow_item._set_values( - id_, - name, - description, - webpage_url, - created_at, - updated_at, - tags, - None, - project_name, - owner_id, - ) + flow_item = cls.from_xml(flow_xml, ns) all_flow_items.append(flow_item) return all_flow_items + @classmethod + def from_xml(cls, flow_xml, ns) -> "FlowItem": + ( + id_, + name, + description, + webpage_url, + created_at, + updated_at, + tags, + project_id, + project_name, + owner_id, + ) = cls._parse_element(flow_xml, ns) + flow_item = cls(project_id) + flow_item._set_values( + id_, + name, + description, + webpage_url, + created_at, + updated_at, + tags, + None, + project_name, + owner_id, + ) + return flow_item + @staticmethod def _parse_element(flow_xml, ns): id_ = flow_xml.get("id", None) diff --git a/tableauserverclient/models/metric_item.py b/tableauserverclient/models/metric_item.py index 4adc73fa8..e390d2c4d 100644 --- a/tableauserverclient/models/metric_item.py +++ b/tableauserverclient/models/metric_item.py @@ -5,6 +5,7 @@ from tableauserverclient.datetime_helpers import parse_datetime from .property_decorators import property_is_boolean, property_is_datetime from .tag_item import TagItem +from .permissions_item import Permission class MetricItem(object): @@ -22,6 +23,7 @@ def __init__(self, name: Optional[str] = None): self._view_id: Optional[str] = None self._initial_tags: Set[str] = set() self.tags: Set[str] = set() + self._permissions: Optional[Permission] = None @property def id(self) -> Optional[str]: @@ -110,6 +112,9 @@ def view_id(self) -> Optional[str]: def view_id(self, value: Optional[str]) -> None: self._view_id = value + def _set_permissions(self, permissions): + self._permissions = permissions + def __repr__(self): return "".format(**vars(self)) @@ -123,36 +128,35 @@ def from_response( parsed_response = ET.fromstring(resp) all_metric_xml = parsed_response.findall(".//t:metric", namespaces=ns) for metric_xml in all_metric_xml: - metric_item = cls() - metric_item._id = metric_xml.get("id", None) - metric_item._name = metric_xml.get("name", None) - metric_item._description = metric_xml.get("description", None) - metric_item._webpage_url = metric_xml.get("webpageUrl", None) - metric_item._created_at = parse_datetime(metric_xml.get("createdAt", None)) - metric_item._updated_at = parse_datetime(metric_xml.get("updatedAt", None)) - metric_item._suspended = string_to_bool(metric_xml.get("suspended", "")) - for owner in metric_xml.findall(".//t:owner", namespaces=ns): - metric_item._owner_id = owner.get("id", None) - - for project in metric_xml.findall(".//t:project", namespaces=ns): - metric_item._project_id = project.get("id", None) - metric_item._project_name = project.get("name", None) - - for view in metric_xml.findall(".//t:underlyingView", namespaces=ns): - metric_item._view_id = view.get("id", None) - - tags = set() - tags_elem = metric_xml.find(".//t:tags", namespaces=ns) - if tags_elem is not None: - all_tags = TagItem.from_xml_element(tags_elem, ns) - tags = all_tags - - metric_item.tags = tags - metric_item._initial_tags = tags - - all_metric_items.append(metric_item) + all_metric_items.append(cls.from_xml(metric_xml, ns)) return all_metric_items + @classmethod + def from_xml(cls, metric_xml, ns): + metric_item = cls() + metric_item._id = metric_xml.get("id", None) + metric_item._name = metric_xml.get("name", None) + metric_item._description = metric_xml.get("description", None) + metric_item._webpage_url = metric_xml.get("webpageUrl", None) + metric_item._created_at = parse_datetime(metric_xml.get("createdAt", None)) + metric_item._updated_at = parse_datetime(metric_xml.get("updatedAt", None)) + metric_item._suspended = string_to_bool(metric_xml.get("suspended", "")) + for owner in metric_xml.findall(".//t:owner", namespaces=ns): + metric_item._owner_id = owner.get("id", None) + for project in metric_xml.findall(".//t:project", namespaces=ns): + metric_item._project_id = project.get("id", None) + metric_item._project_name = project.get("name", None) + for view in metric_xml.findall(".//t:underlyingView", namespaces=ns): + metric_item._view_id = view.get("id", None) + tags = set() + tags_elem = metric_xml.find(".//t:tags", namespaces=ns) + if tags_elem is not None: + all_tags = TagItem.from_xml_element(tags_elem, ns) + tags = all_tags + metric_item.tags = tags + metric_item._initial_tags = tags + return metric_item + # Used to convert string represented boolean to a boolean type def string_to_bool(s: str) -> bool: diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 21358431c..393a7990f 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -21,7 +21,7 @@ def __repr__(self): def __init__( self, - name: str, + name: Optional[str] = None, description: Optional[str] = None, content_permissions: Optional[str] = None, parent_id: Optional[str] = None, @@ -104,11 +104,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_empty def name(self, value: str) -> None: self._name = value @@ -173,19 +172,16 @@ def from_response(cls, resp, ns) -> List["ProjectItem"]: all_project_xml = parsed_response.findall(".//t:project", namespaces=ns) for project_xml in all_project_xml: - ( - id, - name, - description, - content_permissions, - parent_id, - owner_id, - ) = cls._parse_element(project_xml) - project_item = cls(name) - project_item._set_values(id, name, description, content_permissions, parent_id, owner_id) + project_item = cls.from_xml(project_xml) all_project_items.append(project_item) return all_project_items + @classmethod + def from_xml(cls, project_xml, namespace=None) -> "ProjectItem": + project_item = cls() + project_item._set_values(*cls._parse_element(project_xml)) + return project_item + @staticmethod def _parse_element(project_xml): id = project_xml.get("id", None) diff --git a/tableauserverclient/models/tableau_types.py b/tableauserverclient/models/tableau_types.py index 9649c7ed9..33fe5eb0c 100644 --- a/tableauserverclient/models/tableau_types.py +++ b/tableauserverclient/models/tableau_types.py @@ -5,23 +5,25 @@ from .project_item import ProjectItem from .view_item import ViewItem from .workbook_item import WorkbookItem +from .metric_item import MetricItem class Resource: Database = "database" Datarole = "datarole" + Table = "table" Datasource = "datasource" Flow = "flow" Lens = "lens" Metric = "metric" Project = "project" - Table = "table" View = "view" Workbook = "workbook" # resource types that have permissions, can be renamed, etc -TableauItem = Union[DatasourceItem, FlowItem, ProjectItem, ViewItem, WorkbookItem] +# todo: refactoring: should actually define TableauItem as an interface and let all these implement it +TableauItem = Union[DatasourceItem, FlowItem, MetricItem, ProjectItem, ViewItem, WorkbookItem] def plural_type(content_type: Resource) -> str: diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 5e3d18fa6..a12f4b557 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -45,6 +45,7 @@ class Roles: class Auth: OpenID = "OpenID" SAML = "SAML" + TableauIDWithMFA = "TableauIDWithMFA" ServerDefault = "ServerDefault" def __init__( diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index 51cceaa9f..ef1fb0e52 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -1,5 +1,6 @@ import copy from datetime import datetime +from requests import Response from typing import Callable, Iterator, List, Optional, Set from defusedxml.ElementTree import fromstring @@ -140,7 +141,7 @@ def _set_permissions(self, permissions: Callable[[], List[PermissionsRule]]) -> self._permissions = permissions @classmethod - def from_response(cls, resp, ns, workbook_id="") -> List["ViewItem"]: + def from_response(cls, resp: "Response", ns, workbook_id="") -> List["ViewItem"]: return cls.from_xml_element(fromstring(resp), ns, workbook_id) @classmethod @@ -148,39 +149,38 @@ def from_xml_element(cls, parsed_response, ns, workbook_id="") -> List["ViewItem all_view_items = list() all_view_xml = parsed_response.findall(".//t:view", namespaces=ns) for view_xml in all_view_xml: - view_item = cls() - usage_elem = view_xml.find(".//t:usage", namespaces=ns) - workbook_elem = view_xml.find(".//t:workbook", namespaces=ns) - 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) - 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) - view_item._name = view_xml.get("name", None) - view_item._content_url = view_xml.get("contentUrl", None) - view_item._sheet_type = view_xml.get("sheetType", None) - - if usage_elem is not None: - total_view = usage_elem.get("totalViewCount", None) - if total_view: - view_item._total_views = int(total_view) - - if owner_elem is not None: - view_item._owner_id = owner_elem.get("id", None) - - if project_elem is not None: - view_item._project_id = project_elem.get("id", None) - - if workbook_id: - view_item._workbook_id = workbook_id - elif workbook_elem is not None: - view_item._workbook_id = workbook_elem.get("id", None) - - if tags_elem is not None: - tags = TagItem.from_xml_element(tags_elem, ns) - view_item.tags = tags - view_item._initial_tags = copy.copy(tags) - + view_item = cls.from_xml(view_xml, ns, workbook_id) all_view_items.append(view_item) return all_view_items + + @classmethod + def from_xml(cls, view_xml, ns, workbook_id="") -> "ViewItem": + view_item = cls() + usage_elem = view_xml.find(".//t:usage", namespaces=ns) + workbook_elem = view_xml.find(".//t:workbook", namespaces=ns) + 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) + 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) + view_item._name = view_xml.get("name", None) + view_item._content_url = view_xml.get("contentUrl", None) + view_item._sheet_type = view_xml.get("sheetType", None) + if usage_elem is not None: + total_view = usage_elem.get("totalViewCount", None) + if total_view: + view_item._total_views = int(total_view) + if owner_elem is not None: + view_item._owner_id = owner_elem.get("id", None) + if project_elem is not None: + view_item._project_id = project_elem.get("id", None) + if workbook_id: + view_item._workbook_id = workbook_id + elif workbook_elem is not None: + view_item._workbook_id = workbook_elem.get("id", None) + if tags_elem is not None: + tags = TagItem.from_xml_element(tags_elem, ns) + view_item.tags = tags + view_item._initial_tags = copy.copy(tags) + return view_item diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index debbf30b5..16e05498b 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -20,7 +20,7 @@ class WorkbookItem(object): - def __init__(self, project_id: str, name: Optional[str] = None, show_tabs: bool = False) -> None: + def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, show_tabs: bool = False) -> None: self._connections = None self._content_url = None self._webpage_url = None @@ -38,7 +38,8 @@ def __init__(self, project_id: str, name: Optional[str] = None, show_tabs: bool self.name = name self._description = None self.owner_id: Optional[str] = None - self.project_id = project_id + # workaround for Personal Space workbooks without a project + self.project_id: Optional[str] = project_id or uuid.uuid4().__str__() self.show_tabs = show_tabs self.hidden_views: Optional[List[str]] = None self.tags: Set[str] = set() @@ -293,49 +294,16 @@ def from_response(cls, resp: str, ns: Dict[str, str]) -> List["WorkbookItem"]: parsed_response = fromstring(resp) all_workbook_xml = parsed_response.findall(".//t:workbook", namespaces=ns) for workbook_xml in all_workbook_xml: - ( - id, - name, - content_url, - webpage_url, - created_at, - description, - updated_at, - size, - show_tabs, - project_id, - project_name, - owner_id, - tags, - views, - data_acceleration_config, - ) = cls._parse_element(workbook_xml, ns) - - # workaround for Personal Space workbooks which won't have a project - if not project_id: - project_id = uuid.uuid4() - - workbook_item = cls(project_id) - workbook_item._set_values( - id, - name, - content_url, - webpage_url, - created_at, - description, - updated_at, - size, - show_tabs, - None, - project_name, - owner_id, - tags, - views, - data_acceleration_config, - ) + workbook_item = cls.from_xml(workbook_xml, ns) all_workbook_items.append(workbook_item) return all_workbook_items + @classmethod + def from_xml(cls, workbook_xml, ns): + workbook_item = cls() + workbook_item._set_values(*cls._parse_element(workbook_xml, ns)) + return workbook_item + @staticmethod def _parse_element(workbook_xml, ns): id = workbook_xml.get("id", None) diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index 5105b3bf4..dcddca259 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -1,14 +1,22 @@ import logging from .endpoint import Endpoint, api -from tableauserverclient.server import RequestFactory -from tableauserverclient.models import FavoriteItem - -from typing import Optional, TYPE_CHECKING - -if TYPE_CHECKING: - from ...models import DatasourceItem, FlowItem, ProjectItem, UserItem, ViewItem, WorkbookItem - from ..request_options import RequestOptions +from requests import Response + +from tableauserverclient.server import RequestFactory, RequestOptions, Resource +from tableauserverclient.models import ( + DatasourceItem, + FavoriteItem, + FlowItem, + MetricItem, + ProjectItem, + UserItem, + ViewItem, + WorkbookItem, + TableauItem, +) + +from typing import Optional logger = logging.getLogger("tableau.endpoint.favorites") @@ -20,74 +28,112 @@ def baseurl(self) -> str: # Gets all favorites @api(version="2.5") - def get(self, user_item: "UserItem", req_options: Optional["RequestOptions"] = None) -> None: + def get(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: logger.info("Querying all favorites for user {0}".format(user_item.name)) url = "{0}/{1}".format(self.baseurl, user_item.id) server_response = self.get_request(url, req_options) - user_item._favorites = FavoriteItem.from_response(server_response.content, self.parent_srv.namespace) + # ---------add to favorites + + @api(version="3.15") + def add_favorite(self, user_item: UserItem, content_type: str, item: TableauItem) -> "Response": + url = "{0}/{1}".format(self.baseurl, user_item.id) + add_req = RequestFactory.Favorite.add_request(item.id, content_type, item.name) + server_response = self.put_request(url, add_req) + logger.info("Favorited {0} for user (ID: {1})".format(item.name, user_item.id)) + return server_response + @api(version="2.0") - def add_favorite_workbook(self, user_item: "UserItem", workbook_item: "WorkbookItem") -> None: + def add_favorite_workbook(self, user_item: UserItem, workbook_item: WorkbookItem) -> None: url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_workbook_req(workbook_item.id, workbook_item.name) server_response = self.put_request(url, add_req) logger.info("Favorited {0} for user (ID: {1})".format(workbook_item.name, user_item.id)) @api(version="2.0") - def add_favorite_view(self, user_item: "UserItem", view_item: "ViewItem") -> None: + def add_favorite_view(self, user_item: UserItem, view_item: ViewItem) -> None: url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_view_req(view_item.id, view_item.name) server_response = self.put_request(url, add_req) logger.info("Favorited {0} for user (ID: {1})".format(view_item.name, user_item.id)) @api(version="2.3") - def add_favorite_datasource(self, user_item: "UserItem", datasource_item: "DatasourceItem") -> None: + def add_favorite_datasource(self, user_item: UserItem, datasource_item: DatasourceItem) -> None: url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_datasource_req(datasource_item.id, datasource_item.name) server_response = self.put_request(url, add_req) logger.info("Favorited {0} for user (ID: {1})".format(datasource_item.name, user_item.id)) @api(version="3.1") - def add_favorite_project(self, user_item: "UserItem", project_item: "ProjectItem") -> None: + def add_favorite_project(self, user_item: UserItem, project_item: ProjectItem) -> None: url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_project_req(project_item.id, project_item.name) server_response = self.put_request(url, add_req) logger.info("Favorited {0} for user (ID: {1})".format(project_item.name, user_item.id)) @api(version="3.3") - def add_favorite_flow(self, user_item: "UserItem", flow_item: "FlowItem") -> None: + def add_favorite_flow(self, user_item: UserItem, flow_item: FlowItem) -> None: url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_flow_req(flow_item.id, flow_item.name) server_response = self.put_request(url, add_req) logger.info("Favorited {0} for user (ID: {1})".format(flow_item.name, user_item.id)) + @api(version="3.3") + def add_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> None: + url = "{0}/{1}".format(self.baseurl, user_item.id) + add_req = RequestFactory.Favorite.add_request(metric_item.id, Resource.Metric, metric_item.name) + server_response = self.put_request(url, add_req) + logger.info("Favorited metric {0} for user (ID: {1})".format(metric_item.name, user_item.id)) + + # ------- delete from favorites + # Response: + """ + + + + + + """ + + @api(version="3.15") + def delete_favorite(self, user_item: UserItem, content_type: Resource, item: TableauItem) -> None: + url = "{0}/{1}/{2}/{3}".format(self.baseurl, user_item.id, content_type, item.id) + logger.info("Removing favorite {0}({1}) for user (ID: {2})".format(content_type, item.id, user_item.id)) + self.delete_request(url) + @api(version="2.0") - def delete_favorite_workbook(self, user_item: "UserItem", workbook_item: "WorkbookItem") -> None: + def delete_favorite_workbook(self, user_item: UserItem, workbook_item: WorkbookItem) -> None: url = "{0}/{1}/workbooks/{2}".format(self.baseurl, user_item.id, workbook_item.id) - logger.info("Removing favorite {0} for user (ID: {1})".format(workbook_item.id, user_item.id)) + logger.info("Removing favorite workbook {0} for user (ID: {1})".format(workbook_item.id, user_item.id)) self.delete_request(url) @api(version="2.0") - def delete_favorite_view(self, user_item: "UserItem", view_item: "ViewItem") -> None: + def delete_favorite_view(self, user_item: UserItem, view_item: ViewItem) -> None: url = "{0}/{1}/views/{2}".format(self.baseurl, user_item.id, view_item.id) - logger.info("Removing favorite {0} for user (ID: {1})".format(view_item.id, user_item.id)) + logger.info("Removing favorite view {0} for user (ID: {1})".format(view_item.id, user_item.id)) self.delete_request(url) @api(version="2.3") - def delete_favorite_datasource(self, user_item: "UserItem", datasource_item: "DatasourceItem") -> None: + def delete_favorite_datasource(self, user_item: UserItem, datasource_item: DatasourceItem) -> None: url = "{0}/{1}/datasources/{2}".format(self.baseurl, user_item.id, datasource_item.id) logger.info("Removing favorite {0} for user (ID: {1})".format(datasource_item.id, user_item.id)) self.delete_request(url) @api(version="3.1") - def delete_favorite_project(self, user_item: "UserItem", project_item: "ProjectItem") -> None: + def delete_favorite_project(self, user_item: UserItem, project_item: ProjectItem) -> None: url = "{0}/{1}/projects/{2}".format(self.baseurl, user_item.id, project_item.id) - logger.info("Removing favorite {0} for user (ID: {1})".format(project_item.id, user_item.id)) + logger.info("Removing favorite project {0} for user (ID: {1})".format(project_item.id, user_item.id)) self.delete_request(url) @api(version="3.3") - def delete_favorite_flow(self, user_item: "UserItem", flow_item: "FlowItem") -> None: - url = "{0}/{1}/projects/{2}".format(self.baseurl, user_item.id, flow_item.id) - logger.info("Removing favorite {0} for user (ID: {1})".format(flow_item.id, user_item.id)) + def delete_favorite_flow(self, user_item: UserItem, flow_item: FlowItem) -> None: + url = "{0}/{1}/flows/{2}".format(self.baseurl, user_item.id, flow_item.id) + logger.info("Removing favorite flow {0} for user (ID: {1})".format(flow_item.id, user_item.id)) + self.delete_request(url) + + @api(version="3.15") + def delete_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> None: + url = "{0}/{1}/metrics/{2}".format(self.baseurl, user_item.id, metric_item.id) + logger.info("Removing favorite metric {0} for user (ID: {1})".format(metric_item.id, user_item.id)) self.delete_request(url) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 050874c91..4140794b4 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -261,12 +261,16 @@ def update_req(self, dqw_item): class FavoriteRequest(object): - def _add_to_req(self, id_: str, target_type: str, label: str) -> bytes: + def add_request(self, id_: Optional[str], target_type: str, label: Optional[str]) -> bytes: """ """ + if id_ is None: + raise ValueError("Cannot add item as favorite without ID") + if label is None: + label = target_type xml_request = ET.Element("tsRequest") favorite_element = ET.SubElement(xml_request, "favorite") target = ET.SubElement(favorite_element, target_type) @@ -280,35 +284,35 @@ def add_datasource_req(self, id_: Optional[str], name: Optional[str]) -> bytes: raise ValueError("id must exist to add to favorites") if name is None: raise ValueError("Name must exist to add to favorites.") - return self._add_to_req(id_, FavoriteItem.Type.Datasource, name) + return self.add_request(id_, Resource.Datasource, name) def add_flow_req(self, id_: Optional[str], name: Optional[str]) -> bytes: if id_ is None: raise ValueError("id must exist to add to favorites") if name is None: raise ValueError("Name must exist to add to favorites.") - return self._add_to_req(id_, FavoriteItem.Type.Flow, name) + return self.add_request(id_, Resource.Flow, name) def add_project_req(self, id_: Optional[str], name: Optional[str]) -> bytes: if id_ is None: raise ValueError("id must exist to add to favorites") if name is None: raise ValueError("Name must exist to add to favorites.") - return self._add_to_req(id_, FavoriteItem.Type.Project, name) + return self.add_request(id_, Resource.Project, name) def add_view_req(self, id_: Optional[str], name: Optional[str]) -> bytes: if id_ is None: raise ValueError("id must exist to add to favorites") if name is None: raise ValueError("Name must exist to add to favorites.") - return self._add_to_req(id_, FavoriteItem.Type.View, name) + return self.add_request(id_, Resource.View, name) def add_workbook_req(self, id_: Optional[str], name: Optional[str]) -> bytes: if id_ is None: raise ValueError("id must exist to add to favorites") if name is None: raise ValueError("Name must exist to add to favorites.") - return self._add_to_req(id_, FavoriteItem.Type.Workbook, name) + return self.add_request(id_, Resource.Workbook, name) class FileuploadRequest(object): @@ -485,7 +489,8 @@ def update_req(self, project_item: "ProjectItem") -> bytes: def create_req(self, project_item: "ProjectItem") -> bytes: xml_request = ET.Element("tsRequest") project_element = ET.SubElement(xml_request, "project") - project_element.attrib["name"] = project_item.name + if project_item.name: + project_element.attrib["name"] = project_item.name if project_item.description: project_element.attrib["description"] = project_item.description if project_item.content_permissions: diff --git a/test/test_favorites.py b/test/test_favorites.py index 9dcc3bb38..6f0be3b3c 100644 --- a/test/test_favorites.py +++ b/test/test_favorites.py @@ -37,6 +37,8 @@ def test_get(self) -> None: self.assertEqual(len(self.user.favorites["datasources"]), 1) workbook = self.user.favorites["workbooks"][0] + print("favorited: ") + print(workbook) view = self.user.favorites["views"][0] datasource = self.user.favorites["datasources"][0] project = self.user.favorites["projects"][0] diff --git a/test/test_project.py b/test/test_project.py index 3c75a0d3c..33d9c3865 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -155,7 +155,7 @@ def test_create(self) -> None: self.assertEqual("9a8f2265-70f3-4494-96c5-e5949d7a1120", new_project.parent_id) def test_create_missing_name(self) -> None: - self.assertRaises(ValueError, TSC.ProjectItem, "") + TSC.ProjectItem() def test_populate_permissions(self) -> None: with open(asset(POPULATE_PERMISSIONS_XML), "rb") as f: diff --git a/test/test_project_model.py b/test/test_project_model.py index a8b96dc4f..6ddaf8607 100644 --- a/test/test_project_model.py +++ b/test/test_project_model.py @@ -4,15 +4,11 @@ class ProjectModelTests(unittest.TestCase): - def test_invalid_name(self): - self.assertRaises(ValueError, TSC.ProjectItem, None) - self.assertRaises(ValueError, TSC.ProjectItem, "") + def test_nullable_name(self): + TSC.ProjectItem(None) + TSC.ProjectItem("") project = TSC.ProjectItem("proj") - with self.assertRaises(ValueError): - project.name = None - - with self.assertRaises(ValueError): - project.name = "" + project.name = None def test_invalid_content_permissions(self): project = TSC.ProjectItem("proj") From 830a3a61c8a85b9c0d8ed58a11b6e27ea98e2cf4 Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 12 Apr 2023 18:22:33 -0700 Subject: [PATCH 033/296] Jac/small things (#1215) https://github.com/tableau/server-client-python/issues/1210 https://github.com/tableau/server-client-python/issues/1087 https://github.com/tableau/server-client-python/issues/1058 https://github.com/tableau/server-client-python/issues/456 https://github.com/tableau/server-client-python/issues/1209 --- tableauserverclient/models/datasource_item.py | 7 +++---- .../server/endpoint/workbooks_endpoint.py | 3 ++- tableauserverclient/server/request_factory.py | 19 ++++++++++++++----- tableauserverclient/server/request_options.py | 3 ++- test/test_datasource_model.py | 8 +++----- test/test_view.py | 16 ++++++++++++++++ 6 files changed, 40 insertions(+), 16 deletions(-) diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index b5568a778..dbaa0ff91 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -32,7 +32,7 @@ def __repr__(self): self.project_id, ) - def __init__(self, project_id: str, name: Optional[str] = None) -> None: + def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) -> None: self._ask_data_enablement = None self._certified = None self._certification_note = None @@ -135,12 +135,11 @@ def id(self) -> Optional[str]: return self._id @property - def project_id(self) -> str: + def project_id(self) -> Optional[str]: return self._project_id @project_id.setter - @property_not_nullable - def project_id(self, value: str): + def project_id(self, value: Optional[str]): self._project_id = value @property diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index dc4adafaa..a73b0f0d5 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -310,6 +310,7 @@ def publish( 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 @@ -413,7 +414,7 @@ def publish( # Send the publishing request to server try: - server_response = self.post_request(url, xml_request, content_type) + server_response = self.post_request(url, xml_request, content_type, parameters) except InternalServerError as err: if err.code == 504 and not as_job: err.content = "Timeout error while publishing. Please use asynchronous publishing to avoid timeouts." diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index b19c3cc56..050874c91 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -9,6 +9,8 @@ if TYPE_CHECKING: from tableauserverclient.server import Server +# this file could be largely replaced if we were willing to import the huge file from generateDS + def _add_multipart(parts: Dict) -> Tuple[Any, str]: mime_multipart_parts = list() @@ -146,10 +148,11 @@ def update_req(self, database_item): class DatasourceRequest(object): - def _generate_xml(self, datasource_item, connection_credentials=None, connections=None): + def _generate_xml(self, datasource_item: DatasourceItem, connection_credentials=None, connections=None): xml_request = ET.Element("tsRequest") datasource_element = ET.SubElement(xml_request, "datasource") - datasource_element.attrib["name"] = datasource_item.name + if datasource_item.name: + datasource_element.attrib["name"] = datasource_item.name if datasource_item.description: datasource_element.attrib["description"] = str(datasource_item.description) if datasource_item.use_remote_query_agent is not None: @@ -157,10 +160,16 @@ def _generate_xml(self, datasource_item, connection_credentials=None, connection if datasource_item.ask_data_enablement: ask_data_element = ET.SubElement(datasource_element, "askData") - ask_data_element.attrib["enablement"] = datasource_item.ask_data_enablement + ask_data_element.attrib["enablement"] = datasource_item.ask_data_enablement.__str__() - project_element = ET.SubElement(datasource_element, "project") - project_element.attrib["id"] = datasource_item.project_id + if datasource_item.certified: + datasource_element.attrib["isCertified"] = datasource_item.certified.__str__() + if datasource_item.certification_note: + datasource_element.attrib["certificationNote"] = datasource_item.certification_note + + if datasource_item.project_id: + project_element = ET.SubElement(datasource_element, "project") + project_element.attrib["id"] = datasource_item.project_id if connection_credentials is not None and connections is not None: raise RuntimeError("You cannot set both `connections` and `connection_credentials`") diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index fa0a2d68a..1ee18e9df 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -38,6 +38,7 @@ class Operator: class Field: Args = "args" CompletedAt = "completedAt" + ContentUrl = "contentUrl" CreatedAt = "createdAt" DomainName = "domainName" DomainNickname = "domainNickname" @@ -147,7 +148,7 @@ def get_query_params(self): return params -class ExcelRequestOptions(RequestOptionsBase): +class ExcelRequestOptions(_FilterOptionsBase): def __init__(self, maxage: int = -1) -> None: super().__init__() self.max_age = maxage diff --git a/test/test_datasource_model.py b/test/test_datasource_model.py index 2360574ec..655284194 100644 --- a/test/test_datasource_model.py +++ b/test/test_datasource_model.py @@ -3,11 +3,9 @@ class DatasourceModelTests(unittest.TestCase): - def test_invalid_project_id(self): - self.assertRaises(ValueError, TSC.DatasourceItem, None) - datasource = TSC.DatasourceItem("10") - with self.assertRaises(ValueError): - datasource.project_id = None + def test_nullable_project_id(self): + datasource = TSC.DatasourceItem(name="10") + self.assertEqual(datasource.project_id, None) def test_require_boolean_flag_bridge_fail(self): datasource = TSC.DatasourceItem("10") diff --git a/test/test_view.py b/test/test_view.py index f5d3db47b..1459150bb 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -299,3 +299,19 @@ def test_populate_excel(self) -> None: excel_file = b"".join(single_view.excel) self.assertEqual(response, excel_file) + + def test_filter_excel(self) -> None: + self.server.version = "3.8" + self.baseurl = self.server.views.baseurl + with open(POPULATE_EXCEL, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/crosstab/excel?maxAge=1", content=response) + single_view = TSC.ViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + request_option = TSC.ExcelRequestOptions(maxage=1) + request_option.vf("stuff", "1") + self.server.views.populate_excel(single_view, request_option) + + excel_file = b"".join(single_view.excel) + self.assertEqual(response, excel_file) From a29d6ebb6e6f95600567f72129b18be83c82f314 Mon Sep 17 00:00:00 2001 From: Jac Date: Mon, 24 Apr 2023 12:03:04 -0700 Subject: [PATCH 034/296] update datasource to use bridge (#1224) Update request_factory.py --- tableauserverclient/server/request_factory.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 050874c91..91a120512 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -197,6 +197,8 @@ def update_req(self, datasource_item): if datasource_item.owner_id: owner_element = ET.SubElement(datasource_element, "owner") owner_element.attrib["id"] = datasource_item.owner_id + if datasource_item.use_remote_query_agent is not None: + datasource_element.attrib["useRemoteQueryAgent"] = str(datasource_item.use_remote_query_agent).lower() datasource_element.attrib["isCertified"] = str(datasource_item.certified).lower() From beda2d88057c3b1da3f3aba536e25f28aa73c4ca Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Mon, 24 Apr 2023 12:10:37 -0700 Subject: [PATCH 035/296] fix imports --- .../server/endpoint/favorites_endpoint.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index d33452b30..ac9e4b185 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -1,25 +1,22 @@ -import logging - from .endpoint import Endpoint, api from requests import Response -from tableauserverclient.server import RequestFactory, RequestOptions, Resource +from tableauserverclient.helpers.logging import logger from tableauserverclient.models import ( DatasourceItem, FavoriteItem, FlowItem, MetricItem, ProjectItem, + Resource, + TableauItem, UserItem, ViewItem, WorkbookItem, - TableauItem, ) - +from tableauserverclient.server import RequestFactory, RequestOptions from typing import Optional -from tableauserverclient.helpers.logging import logger - class Favorites(Endpoint): @property From 307d8a20a30f32c1ce615cca7c6a78b9b9bff081 Mon Sep 17 00:00:00 2001 From: Jac Date: Mon, 24 Apr 2023 13:08:23 -0700 Subject: [PATCH 036/296] 0.26 logging updates, long running uploads (#1222) TableauIDWithMFA added to the user_item model to allow creating users on Tableau Cloud with MFA enabled (#1217) Run long requests on second thread (#1212) https://github.com/tableau/server-client-python/issues/1210 https://github.com/tableau/server-client-python/issues/1087 https://github.com/tableau/server-client-python/issues/1058 https://github.com/tableau/server-client-python/issues/456 https://github.com/tableau/server-client-python/issues/1209 update datasource to use bridge (#1224) Co-authored-by: Tim Payne <47423639+ma7tcsp@users.noreply.github.com> --- .gitignore | 1 + samples/add_default_permission.py | 10 +- samples/create_group.py | 10 +- samples/create_project.py | 10 +- samples/create_schedules.py | 10 +- samples/explore_datasource.py | 10 +- samples/explore_site.py | 10 +- samples/explore_webhooks.py | 10 +- samples/explore_workbook.py | 10 +- samples/export.py | 10 +- samples/extracts.py | 10 +- samples/filter_sort_groups.py | 10 +- samples/filter_sort_projects.py | 10 +- samples/initialize_server.py | 14 +-- samples/kill_all_jobs.py | 10 +- samples/list.py | 10 +- samples/login.py | 21 +++- samples/metadata_query.py | 10 +- samples/move_workbook_projects.py | 14 +-- samples/move_workbook_sites.py | 14 +-- samples/pagination_sample.py | 10 +- samples/publish_datasource.py | 40 +++++-- samples/publish_workbook.py | 12 +- samples/query_permissions.py | 10 +- samples/refresh.py | 10 +- samples/refresh_tasks.py | 10 +- samples/set_refresh_schedule.py | 10 +- samples/smoke_test.py | 10 +- samples/update_connection.py | 10 +- samples/update_datasource_data.py | 10 +- tableauserverclient/config.py | 13 +++ tableauserverclient/datetime_helpers.py | 4 + tableauserverclient/helpers/logging.py | 6 + tableauserverclient/models/connection_item.py | 2 +- tableauserverclient/models/favorites_item.py | 4 +- tableauserverclient/models/fileupload_item.py | 4 +- .../models/permissions_item.py | 2 +- .../models/server_info_item.py | 2 +- tableauserverclient/server/__init__.py | 4 +- .../server/endpoint/__init__.py | 6 +- .../server/endpoint/auth_endpoint.py | 2 +- .../server/endpoint/custom_views_endpoint.py | 2 +- .../data_acceleration_report_endpoint.py | 2 +- .../server/endpoint/data_alert_endpoint.py | 2 +- .../server/endpoint/databases_endpoint.py | 2 +- .../server/endpoint/datasources_endpoint.py | 36 +++--- .../endpoint/default_permissions_endpoint.py | 2 +- .../server/endpoint/dqw_endpoint.py | 2 +- .../server/endpoint/endpoint.py | 109 ++++++++++++++++-- .../server/endpoint/exceptions.py | 6 +- .../server/endpoint/favorites_endpoint.py | 11 +- .../server/endpoint/fileuploads_endpoint.py | 21 ++-- .../server/endpoint/flow_runs_endpoint.py | 2 +- .../server/endpoint/flows_endpoint.py | 10 +- .../server/endpoint/groups_endpoint.py | 2 +- .../server/endpoint/jobs_endpoint.py | 2 +- .../server/endpoint/metadata_endpoint.py | 2 +- .../server/endpoint/metrics_endpoint.py | 2 +- .../server/endpoint/permissions_endpoint.py | 2 +- .../server/endpoint/projects_endpoint.py | 2 +- .../server/endpoint/resource_tagger.py | 6 +- .../server/endpoint/schedules_endpoint.py | 3 +- .../server/endpoint/server_info_endpoint.py | 6 +- .../server/endpoint/sites_endpoint.py | 2 +- .../server/endpoint/subscriptions_endpoint.py | 2 +- .../server/endpoint/tables_endpoint.py | 2 +- .../server/endpoint/tasks_endpoint.py | 2 +- .../server/endpoint/users_endpoint.py | 2 +- .../server/endpoint/views_endpoint.py | 2 +- .../server/endpoint/webhooks_endpoint.py | 2 +- .../server/endpoint/workbooks_endpoint.py | 3 +- tableauserverclient/server/exceptions.py | 9 +- tableauserverclient/server/request_factory.py | 2 + tableauserverclient/server/request_options.py | 2 +- tableauserverclient/server/server.py | 25 ++-- test/test_auth.py | 6 +- test/test_datasource.py | 6 +- test/test_endpoint.py | 25 +++- test/test_fileuploads.py | 4 +- test/test_request_option.py | 6 +- test/test_webhook.py | 3 +- 81 files changed, 401 insertions(+), 333 deletions(-) create mode 100644 tableauserverclient/config.py create mode 100644 tableauserverclient/helpers/logging.py diff --git a/.gitignore b/.gitignore index d8caf99a9..f0226c065 100644 --- a/.gitignore +++ b/.gitignore @@ -84,6 +84,7 @@ celerybeat-schedule # dotenv .env +env.py # virtualenv venv/ diff --git a/samples/add_default_permission.py b/samples/add_default_permission.py index 8a87c1fd6..5a450e8ab 100644 --- a/samples/add_default_permission.py +++ b/samples/add_default_permission.py @@ -18,14 +18,10 @@ def main(): parser = argparse.ArgumentParser(description="Add workbook default permissions for a given project.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/create_group.py b/samples/create_group.py index 2229f7f26..f4c6a9ca9 100644 --- a/samples/create_group.py +++ b/samples/create_group.py @@ -20,14 +20,10 @@ def main(): parser = argparse.ArgumentParser(description="Creates a sample user group.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/create_project.py b/samples/create_project.py index 8b2ec3354..611dbe366 100644 --- a/samples/create_project.py +++ b/samples/create_project.py @@ -28,14 +28,10 @@ def create_project(server, project_item, samples=False): def main(): parser = argparse.ArgumentParser(description="Create new projects.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/create_schedules.py b/samples/create_schedules.py index f193352de..dee088571 100644 --- a/samples/create_schedules.py +++ b/samples/create_schedules.py @@ -17,14 +17,10 @@ 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", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/explore_datasource.py b/samples/explore_datasource.py index aafbe167c..fb45cb45e 100644 --- a/samples/explore_datasource.py +++ b/samples/explore_datasource.py @@ -18,14 +18,10 @@ def main(): parser = argparse.ArgumentParser(description="Explore datasource functions supported by the Server API.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/explore_site.py b/samples/explore_site.py index a181abfec..a2274f1a7 100644 --- a/samples/explore_site.py +++ b/samples/explore_site.py @@ -14,14 +14,10 @@ def main(): parser = argparse.ArgumentParser(description="Explore site updates by the Server API.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/explore_webhooks.py b/samples/explore_webhooks.py index 47e59ac06..77802b1db 100644 --- a/samples/explore_webhooks.py +++ b/samples/explore_webhooks.py @@ -19,14 +19,10 @@ def main(): parser = argparse.ArgumentParser(description="Explore webhook functions supported by the Server API.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/explore_workbook.py b/samples/explore_workbook.py index f242ace70..c61b9b637 100644 --- a/samples/explore_workbook.py +++ b/samples/explore_workbook.py @@ -19,14 +19,10 @@ def main(): parser = argparse.ArgumentParser(description="Explore workbook functions supported by the Server API.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/export.py b/samples/export.py index 4c26770b9..f2783fa6e 100644 --- a/samples/export.py +++ b/samples/export.py @@ -14,14 +14,10 @@ def main(): parser = argparse.ArgumentParser(description="Export a view as an image, PDF, or CSV") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/extracts.py b/samples/extracts.py index c77da89d0..9bd87a473 100644 --- a/samples/extracts.py +++ b/samples/extracts.py @@ -19,14 +19,10 @@ def main(): parser = argparse.ArgumentParser(description="Explore extract functions supported by the Server API.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", help="site name") - parser.add_argument( - "--token-name", "-tn", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-tv", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-tn", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-tv", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/filter_sort_groups.py b/samples/filter_sort_groups.py index 984d8d344..042af32e2 100644 --- a/samples/filter_sort_groups.py +++ b/samples/filter_sort_groups.py @@ -26,14 +26,10 @@ def create_example_group(group_name="Example Group", server=None): def main(): parser = argparse.ArgumentParser(description="Filter and sort groups.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/filter_sort_projects.py b/samples/filter_sort_projects.py index 608f472ba..7aa62a5c1 100644 --- a/samples/filter_sort_projects.py +++ b/samples/filter_sort_projects.py @@ -29,14 +29,10 @@ def create_example_project( def main(): parser = argparse.ArgumentParser(description="Filter and sort projects.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/initialize_server.py b/samples/initialize_server.py index e7ed0139f..cb3d9e1d0 100644 --- a/samples/initialize_server.py +++ b/samples/initialize_server.py @@ -13,14 +13,10 @@ def main(): parser = argparse.ArgumentParser(description="Initialize a server with content.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", @@ -29,8 +25,8 @@ def main(): help="desired logging level (set to error by default)", ) # Options specific to this sample - parser.add_argument("--datasources-folder", "-df", required=True, help="folder containing datasources") - parser.add_argument("--workbooks-folder", "-wf", required=True, help="folder containing workbooks") + parser.add_argument("--datasources-folder", "-df", help="folder containing datasources") + parser.add_argument("--workbooks-folder", "-wf", help="folder containing workbooks") parser.add_argument("--project", required=False, default="Default", help="project to use") args = parser.parse_args() diff --git a/samples/kill_all_jobs.py b/samples/kill_all_jobs.py index 1a833f938..bfebb49b8 100644 --- a/samples/kill_all_jobs.py +++ b/samples/kill_all_jobs.py @@ -13,14 +13,10 @@ def main(): parser = argparse.ArgumentParser(description="Cancel all of the running background jobs.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/list.py b/samples/list.py index b5cdb38a5..8d72fb620 100644 --- a/samples/list.py +++ b/samples/list.py @@ -15,14 +15,10 @@ def main(): parser = argparse.ArgumentParser(description="List out the names and LUIDs for different resource types.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-n", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-n", 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", diff --git a/samples/login.py b/samples/login.py index f3e9d77dc..6a3e9e8b3 100644 --- a/samples/login.py +++ b/samples/login.py @@ -9,6 +9,7 @@ import logging import tableauserverclient as TSC +import env # If a sample has additional arguments, then it should copy this code and insert them after the call to @@ -18,10 +19,15 @@ def set_up_and_log_in(): parser = argparse.ArgumentParser(description="Logs in to the server.") sample_define_common_options(parser) 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) + if not args.server: + args.server = env.server + if not args.site: + args.site = env.site + if not args.token_name: + args.token_name = env.token_name + if not args.token_value: + args.token_value = env.token_value + args.logging_level = "debug" server = sample_connect_to_server(args) print(server.server_info.get()) @@ -30,9 +36,9 @@ def set_up_and_log_in(): def sample_define_common_options(parser): # Common options; please keep these in sync across all samples by copying or calling this method directly - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-t", help="site name") - auth = parser.add_mutually_exclusive_group(required=True) + auth = parser.add_mutually_exclusive_group(required=False) auth.add_argument("--token-name", "-tn", help="name of the personal access token used to sign into the server") auth.add_argument("--username", "-u", help="username to sign into the server") @@ -73,6 +79,9 @@ def sample_connect_to_server(args): # Make sure we use an updated version of the rest apis, and pass in our cert handling choice server = TSC.Server(args.server, use_server_version=True, http_options={"verify": check_ssl_certificate}) server.auth.sign_in(tableau_auth) + server.version = "2.6" + new_site: TSC.SiteItem = TSC.SiteItem("cdnear", content_url=env.site) + server.auth.switch_site(new_site) print("Logged in successfully") return server diff --git a/samples/metadata_query.py b/samples/metadata_query.py index 26f8f94fa..7524453c2 100644 --- a/samples/metadata_query.py +++ b/samples/metadata_query.py @@ -14,14 +14,10 @@ def main(): parser = argparse.ArgumentParser(description="Use the metadata API to get information on a published data source.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-n", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-n", 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", diff --git a/samples/move_workbook_projects.py b/samples/move_workbook_projects.py index be49ec23b..392dc0ff8 100644 --- a/samples/move_workbook_projects.py +++ b/samples/move_workbook_projects.py @@ -17,14 +17,10 @@ def main(): parser = argparse.ArgumentParser(description="Move one workbook from the default project to another.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", @@ -33,8 +29,8 @@ def main(): help="desired logging level (set to error by default)", ) # Options specific to this sample - parser.add_argument("--workbook-name", "-w", required=True, help="name of workbook to move") - parser.add_argument("--destination-project", "-d", required=True, help="name of project to move workbook into") + parser.add_argument("--workbook-name", "-w", help="name of workbook to move") + parser.add_argument("--destination-project", "-d", help="name of project to move workbook into") args = parser.parse_args() diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index 3feb62be2..47af1f2f9 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -22,14 +22,10 @@ def main(): "the default project of another site." ) # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", @@ -38,8 +34,8 @@ def main(): help="desired logging level (set to error by default)", ) # Options specific to this sample - parser.add_argument("--workbook-name", "-w", required=True, help="name of workbook to move") - parser.add_argument("--destination-site", "-d", required=True, help="name of site to move workbook into") + parser.add_argument("--workbook-name", "-w", help="name of workbook to move") + parser.add_argument("--destination-site", "-d", help="name of site to move workbook into") args = parser.parse_args() diff --git a/samples/pagination_sample.py b/samples/pagination_sample.py index b55fef320..a7ae6dc89 100644 --- a/samples/pagination_sample.py +++ b/samples/pagination_sample.py @@ -20,14 +20,10 @@ def main(): parser = argparse.ArgumentParser(description="Demonstrate pagination on the list of workbooks on the server.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-n", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-n", 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", diff --git a/samples/publish_datasource.py b/samples/publish_datasource.py index 8d9e59ea2..5ac768674 100644 --- a/samples/publish_datasource.py +++ b/samples/publish_datasource.py @@ -23,18 +23,17 @@ import tableauserverclient as TSC +import env +import tableauserverclient.datetime_helpers + def main(): parser = argparse.ArgumentParser(description="Publish a datasource to server.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", @@ -43,7 +42,7 @@ def main(): help="desired logging level (set to error by default)", ) # Options specific to this sample - parser.add_argument("--file", "-f", required=True, help="filepath to the datasource to publish") + parser.add_argument("--file", "-f", help="filepath to the datasource to publish") parser.add_argument("--project", help="Project within which to publish the datasource") parser.add_argument("--async", "-a", help="Publishing asynchronously", dest="async_", action="store_true") parser.add_argument("--conn-username", help="connection username") @@ -52,14 +51,27 @@ def main(): parser.add_argument("--conn-oauth", help="connection is configured to use oAuth", action="store_true") args = parser.parse_args() + if not args.server: + args.server = env.server + if not args.site: + args.site = env.site + if not args.token_name: + args.token_name = env.token_name + if not args.token_value: + args.token_value = env.token_value + args.logging = "debug" + args.file = "C:/dev/tab-samples/5M.tdsx" + args.async_ = True # Ensure that both the connection username and password are provided, or none at all if (args.conn_username and not args.conn_password) or (not args.conn_username and args.conn_password): parser.error("Both the connection username and password must be provided") # Set logging level based on user input, or error by default - logging_level = getattr(logging, args.logging_level.upper()) - logging.basicConfig(level=logging_level) + + _logger = logging.getLogger(__name__) + _logger.setLevel(logging.DEBUG) + _logger.addHandler(logging.StreamHandler()) # Sign in to server tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) @@ -94,6 +106,7 @@ def main(): # Publish datasource if args.async_: + print("Publish as a job") # Async publishing, returns a job_item new_job = server.datasources.publish( new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds, as_job=True @@ -104,7 +117,12 @@ def main(): new_datasource = server.datasources.publish( new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds ) - print("Datasource published. Datasource ID: {0}".format(new_datasource.id)) + print( + "{0}Datasource published. Datasource ID: {1}".format( + new_datasource.id, tableauserverclient.datetime_helpers.timestamp() + ) + ) + print("\t\tClosing connection") if __name__ == "__main__": diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index f0edc380c..8a9f45279 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -24,14 +24,10 @@ def main(): parser = argparse.ArgumentParser(description="Publish a workbook to server.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", @@ -40,7 +36,7 @@ def main(): help="desired logging level (set to error by default)", ) # Options specific to this sample - parser.add_argument("--file", "-f", required=True, help="local filepath of the workbook to publish") + parser.add_argument("--file", "-f", help="local filepath of the workbook to publish") parser.add_argument("--as-job", "-a", help="Publishing asynchronously", action="store_true") parser.add_argument("--skip-connection-check", "-c", help="Skip live connection check", action="store_true") diff --git a/samples/query_permissions.py b/samples/query_permissions.py index 7106da934..4e509cd97 100644 --- a/samples/query_permissions.py +++ b/samples/query_permissions.py @@ -15,14 +15,10 @@ def main(): parser = argparse.ArgumentParser(description="Query permissions of a given resource.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/refresh.py b/samples/refresh.py index f90441224..d3e49ed24 100644 --- a/samples/refresh.py +++ b/samples/refresh.py @@ -13,14 +13,10 @@ def main(): parser = argparse.ArgumentParser(description="Trigger a refresh task on a workbook or datasource.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/refresh_tasks.py b/samples/refresh_tasks.py index 2bfc85621..03daedf16 100644 --- a/samples/refresh_tasks.py +++ b/samples/refresh_tasks.py @@ -30,14 +30,10 @@ def handle_info(server, args): def main(): parser = argparse.ArgumentParser(description="Get all of the refresh tasks available on a server") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/set_refresh_schedule.py b/samples/set_refresh_schedule.py index 9b3dbc236..56fd12e62 100644 --- a/samples/set_refresh_schedule.py +++ b/samples/set_refresh_schedule.py @@ -15,14 +15,10 @@ def usage(args): parser = argparse.ArgumentParser(description="Set refresh schedule for a workbook or datasource.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/smoke_test.py b/samples/smoke_test.py index f2dad1048..b23eacdb8 100644 --- a/samples/smoke_test.py +++ b/samples/smoke_test.py @@ -1,8 +1,16 @@ # This sample verifies that tableau server client is installed # and you can run it. It also shows the version of the client. +import logging import tableauserverclient as TSC + +logger = logging.getLogger("Sample") +logger.setLevel(logging.DEBUG) +logger.addHandler(logging.StreamHandler()) + + server = TSC.Server("Fake-Server-Url", use_server_version=False) print("Client details:") -print(TSC.server.endpoint.Endpoint._make_common_headers("fake-token", "any-content")) +logger.info(server.server_address) +logger.debug(TSC.server.endpoint.Endpoint.set_user_agent({})) diff --git a/samples/update_connection.py b/samples/update_connection.py index e27b4477f..4af6592bc 100644 --- a/samples/update_connection.py +++ b/samples/update_connection.py @@ -13,14 +13,10 @@ def main(): parser = argparse.ArgumentParser(description="Update a connection on a datasource or workbook to embed credentials") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/update_datasource_data.py b/samples/update_datasource_data.py index 41f42ee74..f6bc92022 100644 --- a/samples/update_datasource_data.py +++ b/samples/update_datasource_data.py @@ -25,14 +25,10 @@ def main(): description="Delete the `Europe` region from a published `World Indicators` datasource." ) # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/tableauserverclient/config.py b/tableauserverclient/config.py new file mode 100644 index 000000000..67a77f479 --- /dev/null +++ b/tableauserverclient/config.py @@ -0,0 +1,13 @@ +# TODO: check for env variables, else set default values + +ALLOWED_FILE_EXTENSIONS = ["tds", "tdsx", "tde", "hyper", "parquet"] + +BYTES_PER_MB = 1024 * 1024 + +# For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks +CHUNK_SIZE_MB = 5 * 10 # 5MB felt too slow, upped it to 50 + +DELAY_SLEEP_SECONDS = 10 + +# The maximum size of a file that can be published in a single request is 64MB +FILESIZE_LIMIT_MB = 64 diff --git a/tableauserverclient/datetime_helpers.py b/tableauserverclient/datetime_helpers.py index 0d968428d..00f62faf8 100644 --- a/tableauserverclient/datetime_helpers.py +++ b/tableauserverclient/datetime_helpers.py @@ -5,6 +5,10 @@ HOUR = datetime.timedelta(hours=1) +def timestamp(): + return datetime.datetime.now().strftime("%H:%M:%S") + + # This class is a concrete implementation of the abstract base class tzinfo # docs: https://docs.python.org/2.3/lib/datetime-tzinfo.html class UTC(datetime.tzinfo): diff --git a/tableauserverclient/helpers/logging.py b/tableauserverclient/helpers/logging.py new file mode 100644 index 000000000..414d85786 --- /dev/null +++ b/tableauserverclient/helpers/logging.py @@ -0,0 +1,6 @@ +import logging + +# 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/connection_item.py b/tableauserverclient/models/connection_item.py index 4ed06b831..29ffd2700 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -5,6 +5,7 @@ from .connection_credentials import ConnectionCredentials from .property_decorators import property_is_boolean +from tableauserverclient.helpers.logging import logger class ConnectionItem(object): @@ -46,7 +47,6 @@ def query_tagging(self) -> Optional[bool]: def query_tagging(self, value: Optional[bool]): # if connection type = hyper, Snowflake, or Teradata, we can't change this value: it is always true if self._connection_type in ["hyper", "snowflake", "teradata"]: - logger = logging.getLogger("tableauserverclient.models.connection_item") logger.debug( "Cannot update value: Query tagging is always enabled for {} connections".format(self._connection_type) ) diff --git a/tableauserverclient/models/favorites_item.py b/tableauserverclient/models/favorites_item.py index f075d1fc3..987623404 100644 --- a/tableauserverclient/models/favorites_item.py +++ b/tableauserverclient/models/favorites_item.py @@ -11,8 +11,8 @@ from .workbook_item import WorkbookItem from typing import Dict, List -logger = logging.getLogger("tableau.models.favorites_item") - +from tableauserverclient.helpers.logging import logger +from typing import Dict, List, Union FavoriteType = Dict[ str, diff --git a/tableauserverclient/models/fileupload_item.py b/tableauserverclient/models/fileupload_item.py index 7848b94cf..e9bdd25b2 100644 --- a/tableauserverclient/models/fileupload_item.py +++ b/tableauserverclient/models/fileupload_item.py @@ -11,8 +11,8 @@ def upload_session_id(self): return self._upload_session_id @property - def file_size(self): - return self._file_size + def file_size(self) -> int: + return int(self._file_size) @classmethod def from_response(cls, resp, ns): diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 3bdc63092..1602b077f 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -9,7 +9,7 @@ from .reference_item import ResourceReference from .user_item import UserItem -logger = logging.getLogger("tableau.models.permissions_item") +from tableauserverclient.helpers.logging import logger class Permission: diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py index 5f9395880..b180665dd 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -3,6 +3,7 @@ import xml from defusedxml.ElementTree import fromstring +from tableauserverclient.helpers.logging import logger class ServerInfoItem(object): @@ -36,7 +37,6 @@ def rest_api_version(self): @classmethod def from_response(cls, resp, ns): - logger = logging.getLogger("TSC.ServerInfo") try: parsed_response = fromstring(resp) except xml.etree.ElementTree.ParseError as error: diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index bcea2604e..5abe19446 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -10,9 +10,7 @@ from .filter import Filter from .sort import Sort -from ..models import * from .endpoint import * from .server import Server from .pager import Pager -from .exceptions import NotSignedInError -from ..helpers import * +from .endpoint.exceptions import NotSignedInError diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index e8e1bc0f9..c018d8334 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -5,11 +5,7 @@ from .databases_endpoint import Databases from .datasources_endpoint import Datasources from .endpoint import Endpoint, QuerysetEndpoint -from .exceptions import ( - ServerResponseError, - MissingRequiredFieldError, - ServerInfoEndpointNotFoundError, -) +from .exceptions import ServerResponseError, MissingRequiredFieldError from .favorites_endpoint import Favorites from .fileuploads_endpoint import Fileuploads from .flow_runs_endpoint import FlowRuns diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 68d75eaa8..6f1ddc35e 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -6,7 +6,7 @@ from .exceptions import ServerResponseError from ..request_factory import RequestFactory -logger = logging.getLogger("tableau.endpoint.auth") +from tableauserverclient.helpers.logging import logger class Auth(Endpoint): diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index 778cafecc..119580609 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -6,7 +6,7 @@ from tableauserverclient.models import CustomViewItem, PaginationItem from tableauserverclient.server import RequestFactory, RequestOptions, ImageRequestOptions -logger = logging.getLogger("tableau.endpoint.custom_views") +from tableauserverclient.helpers.logging import logger """ Get a list of custom views on a site diff --git a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py index 28e5495c5..256a6e766 100644 --- a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py +++ b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py @@ -5,7 +5,7 @@ from .permissions_endpoint import _PermissionsEndpoint from tableauserverclient.models import DataAccelerationReportItem -logger = logging.getLogger("tableau.endpoint.data_acceleration_report") +from tableauserverclient.helpers.logging import logger class DataAccelerationReport(Endpoint): diff --git a/tableauserverclient/server/endpoint/data_alert_endpoint.py b/tableauserverclient/server/endpoint/data_alert_endpoint.py index 5af4e0464..fd02d2e4a 100644 --- a/tableauserverclient/server/endpoint/data_alert_endpoint.py +++ b/tableauserverclient/server/endpoint/data_alert_endpoint.py @@ -5,7 +5,7 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import DataAlertItem, PaginationItem, UserItem -logger = logging.getLogger("tableau.endpoint.dataAlerts") +from tableauserverclient.helpers.logging import logger from typing import List, Optional, TYPE_CHECKING, Tuple, Union diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index 2522ef53e..125996277 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -8,7 +8,7 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import DatabaseItem, TableItem, PaginationItem, Resource -logger = logging.getLogger("tableau.endpoint.databases") +from tableauserverclient.helpers.logging import logger class Databases(Endpoint): diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 0c5b8ba61..c60f8f919 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -1,7 +1,6 @@ import cgi import copy import json -import logging import io import os @@ -20,13 +19,14 @@ from .permissions_endpoint import _PermissionsEndpoint from .resource_tagger import _ResourceTagger -from tableauserverclient.server import RequestFactory, RequestOptions +from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, FILESIZE_LIMIT_MB, BYTES_PER_MB, CHUNK_SIZE_MB from tableauserverclient.filesys_helpers import ( - to_filename, make_download_path, get_file_type, get_file_object_size, + to_filename, ) +from tableauserverclient.helpers.logging import logger from tableauserverclient.models import ( ConnectionCredentials, ConnectionItem, @@ -35,6 +35,7 @@ RevisionItem, PaginationItem, ) +from tableauserverclient.server import RequestFactory, RequestOptions io_types = (io.BytesIO, io.BufferedReader) io_types_r = (io.BytesIO, io.BufferedReader) @@ -44,13 +45,6 @@ FileObject = Union[io.BufferedReader, io.BytesIO] PathOrFile = Union[FilePath, FileObject] -# The maximum size of a file that can be published in a single request is 64MB -FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB - -ALLOWED_FILE_EXTENSIONS = ["tds", "tdsx", "tde", "hyper", "parquet"] - -logger = logging.getLogger("tableau.endpoint.datasources") - FilePath = Union[str, os.PathLike] FileObjectR = Union[io.BufferedReader, io.BytesIO] FileObjectW = Union[io.BufferedWriter, io.BytesIO] @@ -162,12 +156,20 @@ def update(self, datasource_item: DatasourceItem) -> DatasourceItem: # Update datasource connections @api(version="2.3") - def update_connection(self, datasource_item: DatasourceItem, connection_item: ConnectionItem) -> ConnectionItem: + def update_connection( + self, datasource_item: DatasourceItem, connection_item: ConnectionItem + ) -> Optional[ConnectionItem]: url = "{0}/{1}/connections/{2}".format(self.baseurl, datasource_item.id, connection_item.id) update_req = RequestFactory.Connection.update_req(connection_item) server_response = self.put_request(url, update_req) - connection = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] + connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) + if not connections: + return None + + if len(connections) > 1: + logger.debug("Multiple connections returned ({0})".format(len(connections))) + connection = list(filter(lambda x: x.id == connection_item.id, connections))[0] logger.info( "Updated datasource item (ID: {0} & connection item {1}".format(datasource_item.id, connection_item.id) @@ -220,7 +222,7 @@ def publish( filename = os.path.basename(file) file_extension = os.path.splitext(filename)[1][1:] file_size = os.path.getsize(file) - + logger.debug("Publishing file `{}`, size `{}`".format(filename, file_size)) # If name is not defined, grab the name from the file to publish if not datasource_item.name: datasource_item.name = os.path.splitext(filename)[0] @@ -261,8 +263,12 @@ def publish( url += "&{0}=true".format("asJob") # Determine if chunking is required (64MB is the limit for single upload method) - if file_size >= FILESIZE_LIMIT: - logger.info("Publishing {0} to server with chunking method (datasource over 64MB)".format(filename)) + if file_size >= FILESIZE_LIMIT_MB * BYTES_PER_MB: + logger.info( + "Publishing {} to server with chunking method (datasource over {}MB, chunk size {}MB)".format( + filename, FILESIZE_LIMIT_MB, CHUNK_SIZE_MB + ) + ) upload_session_id = self.parent_srv.fileuploads.upload(file) url = "{0}&uploadSessionId={1}".format(url, upload_session_id) xml_request, content_type = RequestFactory.Datasource.publish_req_chunked( diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py index b0d16efaf..19112d713 100644 --- a/tableauserverclient/server/endpoint/default_permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py @@ -10,7 +10,7 @@ from ..server import Server from ..request_options import RequestOptions -logger = logging.getLogger(__name__) +from tableauserverclient.helpers.logging import logger # these are the only two items that can hold default permissions for another type BaseItem = Union[DatabaseItem, ProjectItem] diff --git a/tableauserverclient/server/endpoint/dqw_endpoint.py b/tableauserverclient/server/endpoint/dqw_endpoint.py index 96cb7c5f9..5296523ee 100644 --- a/tableauserverclient/server/endpoint/dqw_endpoint.py +++ b/tableauserverclient/server/endpoint/dqw_endpoint.py @@ -5,7 +5,7 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import DQWItem -logger = logging.getLogger(__name__) +from tableauserverclient.helpers.logging import logger class _DataQualityWarningEndpoint(Endpoint): diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 9c933c9dd..c11a3fb27 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,24 +1,31 @@ +from threading import Thread +from time import sleep +from tableauserverclient import datetime_helpers as datetime + import requests -import logging from packaging.version import Version from functools import wraps from xml.etree.ElementTree import ParseError -from typing import Any, Callable, Dict, Optional, TYPE_CHECKING +from typing import Any, Callable, Dict, Optional, TYPE_CHECKING, Union from .exceptions import ( ServerResponseError, InternalServerError, NonXMLResponseError, - EndpointUnavailableError, + NotSignedInError, ) +from ..exceptions import EndpointUnavailableError + from tableauserverclient.server.query import QuerySet from tableauserverclient import helpers, get_versions +from tableauserverclient.helpers.logging import logger +from tableauserverclient.config import DELAY_SLEEP_SECONDS + if TYPE_CHECKING: from ..server import Server from requests import Response -logger = logging.getLogger("tableau.endpoint") Success_codes = [200, 201, 202, 204] @@ -34,6 +41,8 @@ class Endpoint(object): def __init__(self, parent_srv: "Server"): self.parent_srv = parent_srv + async_response = None + @staticmethod def set_parameters(http_options, auth_token, content, content_type, parameters) -> Dict[str, Any]: parameters = parameters or {} @@ -53,6 +62,8 @@ def set_parameters(http_options, auth_token, content, content_type, parameters) @staticmethod def set_user_agent(parameters): + if "headers" not in parameters: + parameters["headers"] = {} if USER_AGENT_HEADER not in parameters["headers"]: if USER_AGENT_HEADER in parameters: parameters["headers"][USER_AGENT_HEADER] = parameters[USER_AGENT_HEADER] @@ -65,6 +76,59 @@ def set_user_agent(parameters): # return explicitly for testing only return parameters + def _blocking_request(self, method, url, parameters={}) -> Optional["Response"]: + self.async_response = None + response = None + logger.debug("[{}] Begin blocking request to {}".format(datetime.timestamp(), url)) + try: + response = method(url, **parameters) + self.async_response = response + logger.debug("[{}] Call finished".format(datetime.timestamp())) + except Exception as e: + logger.debug("Error making request to server: {}".format(e)) + self.async_response = e + finally: + if response and not self.async_response: + logger.debug("Request response not saved") + return None + logger.debug("[{}] Request complete".format(datetime.timestamp())) + return self.async_response + + def send_request_while_show_progress_threaded( + self, method, url, parameters={}, request_timeout=0 + ) -> Optional["Response"]: + try: + request_thread = Thread(target=self._blocking_request, args=(method, url, parameters)) + request_thread.async_response = -1 # type:ignore # this is an invented attribute for thread comms + request_thread.start() + except Exception as e: + logger.debug("Error starting server request on separate thread: {}".format(e)) + return None + seconds = 0 + minutes = 0 + sleep(1) + if self.async_response != -1: + # a quick return for any immediate responses + return self.async_response + while self.async_response == -1 and (request_timeout == 0 or seconds < request_timeout): + self.log_wait_time_then_sleep(minutes, seconds, url) + seconds = seconds + DELAY_SLEEP_SECONDS + if seconds >= 60: + seconds = 0 + minutes = minutes + 1 + return self.async_response + + def log_wait_time_then_sleep(self, minutes, seconds, url): + logger.debug("{} Waiting....".format(datetime.timestamp())) + if seconds >= 60: # detailed log message ~every minute + if minutes % 5 == 0: + logger.info( + "[{}] Waiting ({} minutes so far) for request to {}".format(datetime.timestamp(), minutes, url) + ) + else: + logger.debug("[{}] Waiting for request to {}".format(datetime.timestamp(), url)) + sleep(DELAY_SLEEP_SECONDS) + def _make_request( self, method: Callable[..., "Response"], @@ -80,36 +144,59 @@ def _make_request( logger.debug("request method {}, url: {}".format(method.__name__, url)) if content: - redacted = helpers.strings.redact_xml(content[:1000]) + redacted = helpers.strings.redact_xml(content[:200]) + # this needs to be under a trace or something, it's a LOT # logger.debug("request content: {}".format(redacted)) - server_response = method(url, **parameters) + # a request can, for stuff like publishing, spin for ages waiting for a response. + # we need some user-facing activity so they know it's not dead. + request_timeout = self.parent_srv.http_options.get("timeout") or 0 + server_response: Optional["Response"] = self.send_request_while_show_progress_threaded( + method, url, parameters, request_timeout + ) + logger.debug("[{}] Async request returned: received {}".format(datetime.timestamp(), server_response)) + # is this blocking retry really necessary? I guess if it was just the threading messing it up? + if server_response is None: + logger.debug(server_response) + logger.debug("[{}] Async request failed: retrying".format(datetime.timestamp())) + server_response = self._blocking_request(method, url, parameters) + if server_response is None: + logger.debug("[{}] Request failed".format(datetime.timestamp())) + raise RuntimeError self._check_status(server_response, url) loggable_response = self.log_response_safely(server_response) - # logger.debug("Server response from {0}:\n\t{1}".format(url, loggable_response)) + logger.debug("Server response from {0}".format(url)) + # logger.debug("\n\t{1}".format(loggable_response)) if content_type == "application/xml": self.parent_srv._namespace.detect(server_response.content) return server_response - def _check_status(self, server_response, url: Optional[str] = None): + def _check_status(self, server_response: "Response", url: Optional[str] = None): + logger.debug("Response status: {}".format(server_response)) + if not hasattr(server_response, "status_code"): + raise EnvironmentError("Response is not a http response?") if server_response.status_code >= 500: raise InternalServerError(server_response, url) elif server_response.status_code not in Success_codes: try: + if server_response.status_code == 401: + # TODO: catch this in server.py and attempt to sign in again, in case it's a session expiry + raise NotSignedInError(server_response.content, url) + raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace, url) except ParseError: # This will happen if we get a non-success HTTP code that doesn't return an xml error object - # e.g metadata endpoints, 503 pages, totally different servers + # e.g. metadata endpoints, 503 pages, totally different servers # we convert this to a better exception and pass through the raw response body raise NonXMLResponseError(server_response.content) except Exception: # anything else re-raise here raise - def log_response_safely(self, server_response: requests.Response) -> str: + def log_response_safely(self, server_response: "Response") -> str: # Checking the content type header prevents eager evaluation of streaming requests. content_type = server_response.headers.get("Content-Type") @@ -117,7 +204,7 @@ def log_response_safely(self, server_response: requests.Response) -> str: # content-type is an octet-stream accomplishes the same goal without eagerly loading content. # This check is to determine if the response is a text response (xml or otherwise) # so that we do not attempt to log bytes and other binary data. - loggable_response = "Content type {}".format(content_type) + loggable_response = "Content type `{}`".format(content_type) if content_type == "application/octet-stream": loggable_response = "A stream of type {} [Truncated File Contents]".format(content_type) elif server_response.encoding and len(server_response.content) > 0: diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index d7b1d5ad2..9dfd38da6 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -47,11 +47,7 @@ class MissingRequiredFieldError(TableauError): pass -class ServerInfoEndpointNotFoundError(TableauError): - pass - - -class EndpointUnavailableError(TableauError): +class NotSignedInError(TableauError): pass diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index dcddca259..ac9e4b185 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -1,25 +1,22 @@ -import logging - from .endpoint import Endpoint, api from requests import Response -from tableauserverclient.server import RequestFactory, RequestOptions, Resource +from tableauserverclient.helpers.logging import logger from tableauserverclient.models import ( DatasourceItem, FavoriteItem, FlowItem, MetricItem, ProjectItem, + Resource, + TableauItem, UserItem, ViewItem, WorkbookItem, - TableauItem, ) - +from tableauserverclient.server import RequestFactory, RequestOptions from typing import Optional -logger = logging.getLogger("tableau.endpoint.favorites") - class Favorites(Endpoint): @property diff --git a/tableauserverclient/server/endpoint/fileuploads_endpoint.py b/tableauserverclient/server/endpoint/fileuploads_endpoint.py index 9a8e9560d..a0e29e508 100644 --- a/tableauserverclient/server/endpoint/fileuploads_endpoint.py +++ b/tableauserverclient/server/endpoint/fileuploads_endpoint.py @@ -1,13 +1,10 @@ -import logging - from .endpoint import Endpoint, api -from tableauserverclient.server import RequestFactory -from tableauserverclient.models import FileuploadItem +from tableauserverclient import datetime_helpers as datetime +from tableauserverclient.helpers.logging import logger -# For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks -CHUNK_SIZE = 1024 * 1024 * 5 # 5MB - -logger = logging.getLogger("tableau.endpoint.fileuploads") +from tableauserverclient.config import BYTES_PER_MB, CHUNK_SIZE_MB +from tableauserverclient.models import FileuploadItem +from tableauserverclient.server import RequestFactory class Fileuploads(Endpoint): @@ -44,7 +41,7 @@ def _read_chunks(self, file): try: while True: - chunked_content = file_content.read(CHUNK_SIZE) + chunked_content = file_content.read(CHUNK_SIZE_MB * BYTES_PER_MB) if not chunked_content: break yield chunked_content @@ -55,8 +52,12 @@ def _read_chunks(self, file): def upload(self, file): upload_id = self.initiate() for chunk in self._read_chunks(file): + logger.debug("{} processing chunk...".format(datetime.timestamp())) request, content_type = RequestFactory.Fileupload.chunk_req(chunk) + logger.debug("{} created chunk request".format(datetime.timestamp())) fileupload_item = self.append(upload_id, request, content_type) - logger.info("\tPublished {0}MB".format(fileupload_item.file_size)) + logger.info( + "\t{0} Published {1}MB".format(datetime.timestamp(), (fileupload_item.file_size / BYTES_PER_MB)) + ) logger.info("File upload finished (ID: {0})".format(upload_id)) return upload_id diff --git a/tableauserverclient/server/endpoint/flow_runs_endpoint.py b/tableauserverclient/server/endpoint/flow_runs_endpoint.py index 3bca93a7f..63b32e006 100644 --- a/tableauserverclient/server/endpoint/flow_runs_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_runs_endpoint.py @@ -6,7 +6,7 @@ from tableauserverclient.models import FlowRunItem, PaginationItem from tableauserverclient.exponential_backoff import ExponentialBackoffTimer -logger = logging.getLogger("tableau.endpoint.flowruns") +from tableauserverclient.helpers.logging import logger if TYPE_CHECKING: from ..server import Server diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 4d97110c4..ba8a152d7 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -32,13 +32,13 @@ ALLOWED_FILE_EXTENSIONS = ["tfl", "tflx"] -logger = logging.getLogger("tableau.endpoint.flows") +from tableauserverclient.helpers.logging import logger if TYPE_CHECKING: - from .. import DQWItem - from ..request_options import RequestOptions - from ...models.permissions_item import PermissionsRule - from .schedules_endpoint import AddResponse + from tableauserverclient.models import DQWItem + from tableauserverclient.models.permissions_item import PermissionsRule + from tableauserverclient.server.request_options import RequestOptions + from tableauserverclient.server.endpoint.schedules_endpoint import AddResponse FilePath = Union[str, os.PathLike] diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index ba5b6649b..ad3828568 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -6,7 +6,7 @@ from tableauserverclient.models import GroupItem, UserItem, PaginationItem, JobItem from ..pager import Pager -logger = logging.getLogger("tableau.endpoint.groups") +from tableauserverclient.helpers.logging import logger from typing import List, Optional, TYPE_CHECKING, Tuple, Union diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index dd210d990..d0b865e21 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -6,7 +6,7 @@ from ..request_options import RequestOptionsBase from tableauserverclient.exponential_backoff import ExponentialBackoffTimer -logger = logging.getLogger("tableau.endpoint.jobs") +from tableauserverclient.helpers.logging import logger from typing import List, Optional, Tuple, Union diff --git a/tableauserverclient/server/endpoint/metadata_endpoint.py b/tableauserverclient/server/endpoint/metadata_endpoint.py index 06339fa79..39146d062 100644 --- a/tableauserverclient/server/endpoint/metadata_endpoint.py +++ b/tableauserverclient/server/endpoint/metadata_endpoint.py @@ -4,7 +4,7 @@ from .endpoint import Endpoint, api from .exceptions import GraphQLError, InvalidGraphQLQuery -logger = logging.getLogger("tableau.endpoint.metadata") +from tableauserverclient.helpers.logging import logger def is_valid_paged_query(parsed_query): diff --git a/tableauserverclient/server/endpoint/metrics_endpoint.py b/tableauserverclient/server/endpoint/metrics_endpoint.py index 8443726cd..a0e984475 100644 --- a/tableauserverclient/server/endpoint/metrics_endpoint.py +++ b/tableauserverclient/server/endpoint/metrics_endpoint.py @@ -15,7 +15,7 @@ from ...server import Server -logger = logging.getLogger("tableau.endpoint.metrics") +from tableauserverclient.helpers.logging import logger class Metrics(QuerysetEndpoint): diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py index e50e32945..4433625f2 100644 --- a/tableauserverclient/server/endpoint/permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/permissions_endpoint.py @@ -8,7 +8,7 @@ from typing import Callable, TYPE_CHECKING, List, Optional, Union -logger = logging.getLogger(__name__) +from tableauserverclient.helpers.logging import logger if TYPE_CHECKING: from ..server import Server diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 440940606..510f1ff3d 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -13,7 +13,7 @@ from ..server import Server from ..request_options import RequestOptions -logger = logging.getLogger("tableau.endpoint.projects") +from tableauserverclient.helpers.logging import logger class Projects(QuerysetEndpoint): diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index 18c38798e..8177bd733 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -1,13 +1,13 @@ import copy -import logging import urllib.parse from .endpoint import Endpoint -from .exceptions import EndpointUnavailableError, ServerResponseError +from .exceptions import ServerResponseError +from ..exceptions import EndpointUnavailableError from tableauserverclient.server import RequestFactory from tableauserverclient.models import TagItem -logger = logging.getLogger("tableau.endpoint.resource_tagger") +from tableauserverclient.helpers.logging import logger class _ResourceTagger(Endpoint): diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index 7cca1f5d5..cfaee3324 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -9,7 +9,8 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import PaginationItem, ScheduleItem, TaskItem -logger = logging.getLogger("tableau.endpoint.schedules") +from tableauserverclient.helpers.logging import logger + AddResponse = namedtuple("AddResponse", ("result", "error", "warnings", "task_created")) OK = AddResponse(result=True, error=None, warnings=None, task_created=None) diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index b396a1f87..26aaf2910 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -1,15 +1,13 @@ import logging from .endpoint import Endpoint, api -from .exceptions import ( - ServerResponseError, +from .exceptions import ServerResponseError +from ..exceptions import ( ServerInfoEndpointNotFoundError, EndpointUnavailableError, ) from tableauserverclient.models import ServerInfoItem -logger = logging.getLogger("tableau.endpoint.server_info") - class ServerInfo(Endpoint): def __init__(self, server): diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index a4c765484..dfec49ae1 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -6,7 +6,7 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import SiteItem, PaginationItem -logger = logging.getLogger("tableau.endpoint.sites") +from tableauserverclient.helpers.logging import logger from typing import TYPE_CHECKING, List, Optional, Tuple diff --git a/tableauserverclient/server/endpoint/subscriptions_endpoint.py b/tableauserverclient/server/endpoint/subscriptions_endpoint.py index a81a2fbf0..a9f2e7bf5 100644 --- a/tableauserverclient/server/endpoint/subscriptions_endpoint.py +++ b/tableauserverclient/server/endpoint/subscriptions_endpoint.py @@ -5,7 +5,7 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import SubscriptionItem, PaginationItem -logger = logging.getLogger("tableau.endpoint.subscriptions") +from tableauserverclient.helpers.logging import logger from typing import List, Optional, TYPE_CHECKING, Tuple diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index e51f885d7..dfb2e6d7c 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -8,7 +8,7 @@ from tableauserverclient.models import TableItem, ColumnItem, PaginationItem from ..pager import Pager -logger = logging.getLogger("tableau.endpoint.tables") +from tableauserverclient.helpers.logging import logger class Tables(Endpoint): diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index b903ac634..ad1702f58 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -5,7 +5,7 @@ from tableauserverclient.models import TaskItem, PaginationItem from tableauserverclient.server import RequestFactory -logger = logging.getLogger("tableau.endpoint.tasks") +from tableauserverclient.helpers.logging import logger class Tasks(Endpoint): diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 5a9c74619..e8c5cc962 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -8,7 +8,7 @@ from tableauserverclient.models import UserItem, WorkbookItem, PaginationItem, GroupItem from ..pager import Pager -logger = logging.getLogger("tableau.endpoint.users") +from tableauserverclient.helpers.logging import logger class Users(QuerysetEndpoint): diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index c060298ba..9c4b90657 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -7,7 +7,7 @@ from .resource_tagger import _ResourceTagger from tableauserverclient.models import ViewItem, PaginationItem -logger = logging.getLogger("tableau.endpoint.views") +from tableauserverclient.helpers.logging import logger from typing import Iterator, List, Optional, Tuple, TYPE_CHECKING diff --git a/tableauserverclient/server/endpoint/webhooks_endpoint.py b/tableauserverclient/server/endpoint/webhooks_endpoint.py index 69a958988..597f9c425 100644 --- a/tableauserverclient/server/endpoint/webhooks_endpoint.py +++ b/tableauserverclient/server/endpoint/webhooks_endpoint.py @@ -4,7 +4,7 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import WebhookItem, PaginationItem -logger = logging.getLogger("tableau.endpoint.webhooks") +from tableauserverclient.helpers.logging import logger from typing import List, Optional, TYPE_CHECKING, Tuple diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 5e2784b55..a73b0f0d5 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -44,7 +44,8 @@ ALLOWED_FILE_EXTENSIONS = ["twb", "twbx"] -logger = logging.getLogger("tableau.endpoint.workbooks") +from tableauserverclient.helpers.logging import logger + FilePath = Union[str, os.PathLike] FileObject = Union[io.BufferedReader, io.BytesIO] FileObjectR = Union[io.BufferedReader, io.BytesIO] diff --git a/tableauserverclient/server/exceptions.py b/tableauserverclient/server/exceptions.py index 09d3d0541..6c9bbcefc 100644 --- a/tableauserverclient/server/exceptions.py +++ b/tableauserverclient/server/exceptions.py @@ -1,2 +1,9 @@ -class NotSignedInError(Exception): +# These errors can be thrown without even talking to Tableau Server + + +class ServerInfoEndpointNotFoundError(Exception): + pass + + +class EndpointUnavailableError(Exception): pass diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 4140794b4..4bd30bb2c 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -197,6 +197,8 @@ def update_req(self, datasource_item): if datasource_item.owner_id: owner_element = ET.SubElement(datasource_element, "owner") owner_element.attrib["id"] = datasource_item.owner_id + if datasource_item.use_remote_query_agent is not None: + datasource_element.attrib["useRemoteQueryAgent"] = str(datasource_item.use_remote_query_agent).lower() datasource_element.attrib["isCertified"] = str(datasource_item.certified).lower() diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 299b9db2f..1ee18e9df 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,7 +1,7 @@ from tableauserverclient.models.property_decorators import property_is_int import logging -logger = logging.getLogger("tableau.request_options") +from tableauserverclient.helpers.logging import logger class RequestOptionsBase(object): diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 887b9de6d..ee23789b1 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -1,4 +1,4 @@ -import logging +from tableauserverclient.helpers.logging import logger import requests import urllib3 @@ -34,11 +34,11 @@ Metrics, Endpoint, ) -from .endpoint.exceptions import ( +from .exceptions import ( ServerInfoEndpointNotFoundError, EndpointUnavailableError, ) -from .exceptions import NotSignedInError +from .endpoint.exceptions import NotSignedInError from ..namespace import Namespace @@ -99,8 +99,6 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self.metrics = Metrics(self) self.custom_views = CustomViews(self) - self.logger = logging.getLogger("TSC.server") - self._session = self._session_factory() self._http_options = dict() # must set this before making a server call if http_options: @@ -114,7 +112,8 @@ def __init__(self, server_address, use_server_version=False, http_options=None, def validate_connection_settings(self): try: - Endpoint(self).set_parameters(self._http_options, None, None, None, None) + params = Endpoint(self).set_parameters(self._http_options, None, None, None, None) + Endpoint.set_user_agent(params) if not self._server_address.startswith("http://") and not self._server_address.startswith("https://"): self._server_address = "http://" + self._server_address self._session.prepare_request(requests.Request("GET", url=self._server_address, params=self._http_options)) @@ -156,8 +155,8 @@ def _get_legacy_version(self): try: info_xml = fromstring(response.content) except ParseError as parseError: - self.logger.info(parseError) - self.logger.info("Could not read server version info. The server may not be running or configured.") + logger.info(parseError) + logger.info("Could not read server version info. The server may not be running or configured.") return self.version prod_version = info_xml.find(".//product_version").text version = _PRODUCT_TO_REST_VERSION.get(prod_version, minimum_supported_server_version) @@ -168,15 +167,15 @@ def _determine_highest_version(self): old_version = self.version version = self.server_info.get().rest_api_version except ServerInfoEndpointNotFoundError as e: - self.logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) + logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) version = self._get_legacy_version() except EndpointUnavailableError as e: - self.logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) + logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) version = self._get_legacy_version() except Exception as e: - self.logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) + logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) version = None - self.logger.info("versions: {}, {}".format(version, old_version)) + logger.info("versions: {}, {}".format(version, old_version)) return version or old_version def use_server_version(self): @@ -184,7 +183,7 @@ def use_server_version(self): def use_highest_version(self): self.use_server_version() - self.logger.info("use use_server_version instead", DeprecationWarning) + logger.info("use use_server_version instead", DeprecationWarning) def check_at_least_version(self, target: str): server_version = Version(self.version or "2.4") diff --git a/test/test_auth.py b/test/test_auth.py index 40255f627..eaf13481e 100644 --- a/test/test_auth.py +++ b/test/test_auth.py @@ -63,7 +63,7 @@ def test_sign_in_error(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.TableauAuth("testuser", "wrongpassword") - self.assertRaises(TSC.ServerResponseError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) def test_sign_in_invalid_token(self): with open(SIGN_IN_ERROR_XML, "rb") as f: @@ -71,7 +71,7 @@ def test_sign_in_invalid_token(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.PersonalAccessTokenAuth(token_name="mytoken", personal_access_token="invalid") - self.assertRaises(TSC.ServerResponseError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) def test_sign_in_without_auth(self): with open(SIGN_IN_ERROR_XML, "rb") as f: @@ -79,7 +79,7 @@ def test_sign_in_without_auth(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.TableauAuth("", "") - self.assertRaises(TSC.ServerResponseError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) def test_sign_out(self): with open(SIGN_IN_XML, "rb") as f: diff --git a/test/test_datasource.py b/test/test_datasource.py index 4f3529762..730e382da 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -145,9 +145,9 @@ def test_update_copy_fields(self) -> None: def test_update_tags(self) -> None: add_tags_xml, update_xml = read_xml_assets(ADD_TAGS_XML, UPDATE_XML) with requests_mock.mock() as m: - m.put(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags", text=add_tags_xml) m.delete(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags/b", status_code=204) m.delete(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags/d", status_code=204) + m.put(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags", text=add_tags_xml) m.put(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", text=update_xml) single_datasource = TSC.DatasourceItem("1d0304cd-3796-429f-b815-7258370b9b74") single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" @@ -191,7 +191,7 @@ def test_update_connection(self) -> None: self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections/be786ae0-d2bf-4a4b-9b34-e2de8d2d4488", text=response_xml, ) - single_datasource = TSC.DatasourceItem("1d0304cd-3796-429f-b815-7258370b9b74") + single_datasource = TSC.DatasourceItem("be786ae0-d2bf-4a4b-9b34-e2de8d2d4488") single_datasource.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" self.server.datasources.populate_connections(single_datasource) @@ -610,7 +610,7 @@ def test_synchronous_publish_timeout_error(self) -> None: new_datasource = TSC.DatasourceItem(project_id="") publish_mode = self.server.PublishMode.CreateNew - + # http://test/api/2.4/sites/dad65087-b08b-4603-af4e-2887b8aafc67/datasources?datasourceType=tds self.assertRaisesRegex( InternalServerError, "Please use asynchronous publishing to avoid timeouts.", diff --git a/test/test_endpoint.py b/test/test_endpoint.py index 0d8ae84f2..3d2d1c995 100644 --- a/test/test_endpoint.py +++ b/test/test_endpoint.py @@ -15,9 +15,32 @@ def setUp(self) -> None: # Fake signin self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - return super().setUp() + def test_fallback_request_logic(self) -> None: + url = "http://test/" + endpoint = TSC.server.Endpoint(self.server) + with requests_mock.mock() as m: + m.get(url) + response = endpoint.get_request(url=url) + self.assertIsNotNone(response) + + def test_user_friendly_request_returns(self) -> None: + url = "http://test/" + endpoint = TSC.server.Endpoint(self.server) + with requests_mock.mock() as m: + m.get(url) + response = endpoint.send_request_while_show_progress_threaded( + endpoint.parent_srv.session.get, url=url, request_timeout=2 + ) + self.assertIsNotNone(response) + + def test_blocking_request_returns(self) -> None: + url = "http://test/" + endpoint = TSC.server.Endpoint(self.server) + response = endpoint._blocking_request(endpoint.parent_srv.session.get, url=url) + self.assertIsNotNone(response) + def test_get_request_stream(self) -> None: url = "http://test/" endpoint = TSC.server.Endpoint(self.server) diff --git a/test/test_fileuploads.py b/test/test_fileuploads.py index 4d3b0c864..cf0861e24 100644 --- a/test/test_fileuploads.py +++ b/test/test_fileuploads.py @@ -43,7 +43,7 @@ def test_upload_chunks_file_path(self): append_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=initialize_response_xml) - m.put(self.baseurl + "/" + upload_id, text=append_response_xml) + m.put("{}/{}".format(self.baseurl, upload_id), text=append_response_xml) actual = self.server.fileuploads.upload(file_path) self.assertEqual(upload_id, actual) @@ -58,7 +58,7 @@ def test_upload_chunks_file_object(self): append_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=initialize_response_xml) - m.put(self.baseurl + "/" + upload_id, text=append_response_xml) + m.put("{}/{}".format(self.baseurl, upload_id), text=append_response_xml) actual = self.server.fileuploads.upload(file_content) self.assertEqual(upload_id, actual) diff --git a/test/test_request_option.py b/test/test_request_option.py index 9dacbe033..5d8bdf05e 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -22,7 +22,7 @@ class RequestOptionTests(unittest.TestCase): def setUp(self) -> None: - self.server = TSC.Server("http://test", False) + self.server = TSC.Server("http://test", False, http_options={"timeout": 5}) # Fake signin self.server.version = "3.10" @@ -151,7 +151,7 @@ def test_multiple_filter_options(self) -> None: ) ) req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, "foo")) - for _ in range(100): + for _ in range(5): matching_workbooks, pagination_item = self.server.workbooks.get(req_option) self.assertEqual(3, pagination_item.total_available) @@ -245,7 +245,7 @@ def test_multiple_filter_options_shorthand(self) -> None: ) m.get(url, text=response_xml) - for _ in range(100): + for _ in range(5): matching_workbooks = self.server.workbooks.filter(tags__in=["sample", "safari", "weather"], name="foo") self.assertEqual(3, matching_workbooks.total_available) diff --git a/test/test_webhook.py b/test/test_webhook.py index ff8b7048e..5f26266b2 100644 --- a/test/test_webhook.py +++ b/test/test_webhook.py @@ -4,7 +4,8 @@ import requests_mock import tableauserverclient as TSC -from tableauserverclient.server import RequestFactory, WebhookItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import WebhookItem from ._utils import asset TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") From 5650adc5126a7fc0070fb8e68de848af709baa0d Mon Sep 17 00:00:00 2001 From: Lars Breddemann <139097050+LarsBreddemann@users.noreply.github.com> Date: Tue, 25 Jul 2023 15:48:17 +1000 Subject: [PATCH 037/296] 846 fix filter in operator spaces bug (#1259) * encode spaces in filter conditions as %20 * corrected string replacement for filter condition * removed trailing space from comment * added tests for filter with IN condition and spaces in names --- tableauserverclient/server/filter.py | 6 ++++- test/assets/request_option_filter_name_in.xml | 12 +++++++++ test/test_filter.py | 22 ++++++++++++++++ test/test_request_option.py | 25 +++++++++++++++++++ 4 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 test/assets/request_option_filter_name_in.xml create mode 100644 test/test_filter.py 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/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/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_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") From 66064c55b0aee851dba31d50b0ab7c23e0270acf Mon Sep 17 00:00:00 2001 From: jorwoods Date: Mon, 31 Jul 2023 15:46:13 -0500 Subject: [PATCH 038/296] fix: remove logging configuration from TSC (#1248) Co-authored-by: Jac Co-authored-by: Tim Payne <47423639+ma7tcsp@users.noreply.github.com> --- tableauserverclient/helpers/logging.py | 2 -- 1 file changed, 2 deletions(-) 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()) From f56b2c741d7e94630ec1b2d1d6ee4e9c31c5093f Mon Sep 17 00:00:00 2001 From: jorwoods Date: Tue, 1 Aug 2023 02:17:00 -0500 Subject: [PATCH 039/296] feat: add JWTAuth (#1219) * feat: add JWTAuth, add repr using qualname * chore: mark Credentials class and methods as abstract --- tableauserverclient/models/tableau_auth.py | 30 +++++++++++++++++-- .../server/endpoint/auth_endpoint.py | 28 +++++++++++++---- 2 files changed, 49 insertions(+), 9 deletions(-) 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") From 77f2f63e62c860bc7e9f25a2f268e8b0a5e078ac Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 1 Aug 2023 15:30:18 -0700 Subject: [PATCH 040/296] Jac/schedules (#1266) * Hotfix schedule_item.py for issue 1237 (#1239) * Remove duplicate assignments to fields (#1244) Co-authored-by: Tim Payne <47423639+ma7tcsp@users.noreply.github.com> Co-authored-by: Austin <110413815+austinpeters-gohealthuccom@users.noreply.github.com> Co-authored-by: Yasuhisa Yoshida --- tableauserverclient/models/datasource_item.py | 9 --------- tableauserverclient/models/schedule_item.py | 8 ++------ 2 files changed, 2 insertions(+), 15 deletions(-) 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/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 From 574118aed0a8a089fa482fa856e79f62d9c6fdd5 Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 1 Aug 2023 16:29:14 -0700 Subject: [PATCH 041/296] add powerpoint example in samples (#1262) --- samples/explore_workbook.py | 10 ++++++++++ 1 file changed, 10 insertions(+) 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) From 4caf0a5a948ed50a4d259c8ab65f5552b8544ace Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 1 Aug 2023 16:30:31 -0700 Subject: [PATCH 042/296] pin black and mypy versions, update many dependencies (#1265) * pin black and mypy versions * drop python 3.7 support - update build + dependencies to latest versions - check mypy warnings, fix 2 typing complaints * update python versions used in actions to 3.8 -- 3.12 ( github doesn't have an image for 3.12 yet b/c it is still in beta?) --- .github/workflows/run-tests.yml | 4 ++-- pyproject.toml | 26 ++++++++++++++------------ test/models/test_repr.py | 2 +- test/test_datasource.py | 6 ++++-- 4 files changed, 21 insertions(+), 17 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 10df02c04..1f4614088 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.7', '3.8', '3.9', '3.10'] + python-version: ['3.8', '3.9', '3.10', '3.11'] runs-on: ${{ matrix.os }} @@ -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/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_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) From 282159291c0506a9d1ea79077227b59fb032e7e0 Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 1 Aug 2023 16:31:11 -0700 Subject: [PATCH 043/296] Add publish samples attribute (#1264) --- samples/create_project.py | 9 ++++++++- tableauserverclient/models/project_item.py | 2 ++ tableauserverclient/server/endpoint/projects_endpoint.py | 2 ++ 3 files changed, 12 insertions(+), 1 deletion(-) 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/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/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] From 90cf3329d8298d41d0ea941f97f2b0e501fc8e0c Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Tue, 1 Aug 2023 16:45:15 -0700 Subject: [PATCH 044/296] Update actions to newer versions to get supported Node versions (#1267) --- .github/workflows/code-coverage.yml | 4 ++-- .github/workflows/meta-checks.yml | 4 ++-- .github/workflows/publish-pypi.yml | 2 +- .github/workflows/run-tests.yml | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) 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 1f4614088..3df497806 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.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 }} From 15086b8cc200341255feed5392a82f4a8fa6895d Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 1 Aug 2023 16:45:32 -0700 Subject: [PATCH 045/296] Added 'getting started' samples (#1263) * Added 'getting started' samples --- samples/getting_started/1_hello_server.py | 21 +++++ samples/getting_started/2_hello_site.py | 50 +++++++++++ samples/getting_started/3_hello_universe.py | 96 +++++++++++++++++++++ 3 files changed, 167 insertions(+) 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 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() From 01e03727a8c82d13d929c0abcb8c0136dc6a5a40 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Thu, 17 Aug 2023 11:56:54 -0700 Subject: [PATCH 046/296] Fix newline for clean Black run --- tableauserverclient/helpers/logging.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tableauserverclient/helpers/logging.py b/tableauserverclient/helpers/logging.py index 860bed0fc..e64c6d2c8 100644 --- a/tableauserverclient/helpers/logging.py +++ b/tableauserverclient/helpers/logging.py @@ -2,4 +2,3 @@ # TODO change: this defaults to logging *everything* to stdout logger = logging.getLogger("TSC") - From 5a5772ca804502dcfbba9e7b3674e3fc68722459 Mon Sep 17 00:00:00 2001 From: a-torres-2 <142839181+a-torres-2@users.noreply.github.com> Date: Tue, 29 Aug 2023 12:00:25 -0700 Subject: [PATCH 047/296] add support for custom schedules in TOL (#1273) * add support for custom schedules in TOL --- samples/create_extract_task.py | 84 +++++++++++++++++++ .../models/subscription_item.py | 8 ++ .../server/endpoint/tasks_endpoint.py | 11 +++ tableauserverclient/server/request_factory.py | 28 +++++++ test/assets/tasks_create_extract_task.xml | 12 +++ test/test_task.py | 27 +++++- 6 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 samples/create_extract_task.py create mode 100644 test/assets/tasks_create_extract_task.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/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/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/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/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/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 9afc0b30dd08dcebab7ef9a38d291aa46e5c0d30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20W=C5=82odarczyk?= Date: Thu, 21 Sep 2023 22:13:41 +0200 Subject: [PATCH 048/296] Added Filtering Capability for Tableau Download View Crosstab Excel (#1281) add missing filters for crosstab --- 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 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 From 81af54ac7360d8bc929cf54f069d7c75320b4435 Mon Sep 17 00:00:00 2001 From: jorwoods Date: Thu, 21 Sep 2023 15:20:28 -0500 Subject: [PATCH 049/296] Enable asJob for group update (#1276) --- .../server/endpoint/groups_endpoint.py | 5 ++++- test/assets/group_update_async.xml | 10 ++++++++++ test/test_group.py | 19 ++++++++++++++++++- 3 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 test/assets/group_update_async.xml 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/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/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") From 3a49700d00db9c8c450e4248c08696c66d933f82 Mon Sep 17 00:00:00 2001 From: jorwoods Date: Thu, 21 Sep 2023 21:12:32 -0500 Subject: [PATCH 050/296] Fix shared attribute for custom views (#1280) --- tableauserverclient/models/custom_view_item.py | 5 +++++ test/test_custom_view.py | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) 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/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: From f94f72d7dd82cdcff11e72037f699fe376684505 Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 18 Apr 2023 19:59:35 -0700 Subject: [PATCH 051/296] run long requests on second thread (#1212) * run long requests on second thread * improve chunked upload requests * begin extracting constants for user editing * centrally configured logger --- .../server/endpoint/favorites_endpoint.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index ac9e4b185..f6ab7d4b6 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -1,4 +1,5 @@ from .endpoint import Endpoint, api +<<<<<<< HEAD from requests import Response from tableauserverclient.helpers.logging import logger @@ -16,6 +17,18 @@ ) from tableauserverclient.server import RequestFactory, RequestOptions from typing import Optional +======= +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import FavoriteItem + +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from ...models import DatasourceItem, FlowItem, ProjectItem, UserItem, ViewItem, WorkbookItem + from ..request_options import RequestOptions + +from tableauserverclient.helpers.logging import logger +>>>>>>> 3cc28be (run long requests on second thread (#1212)) class Favorites(Endpoint): From c812e4bd24fac32bae4c3060fd805fae907b38a7 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Mon, 24 Apr 2023 12:10:37 -0700 Subject: [PATCH 052/296] fix imports --- .../server/endpoint/favorites_endpoint.py | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index f6ab7d4b6..ee2ba9041 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -1,23 +1,4 @@ from .endpoint import Endpoint, api -<<<<<<< HEAD -from requests import Response - -from tableauserverclient.helpers.logging import logger -from tableauserverclient.models import ( - DatasourceItem, - FavoriteItem, - FlowItem, - MetricItem, - ProjectItem, - Resource, - TableauItem, - UserItem, - ViewItem, - WorkbookItem, -) -from tableauserverclient.server import RequestFactory, RequestOptions -from typing import Optional -======= from tableauserverclient.server import RequestFactory from tableauserverclient.models import FavoriteItem @@ -28,7 +9,6 @@ from ..request_options import RequestOptions from tableauserverclient.helpers.logging import logger ->>>>>>> 3cc28be (run long requests on second thread (#1212)) class Favorites(Endpoint): From 9f2e870ff9bbe8408494b80037fff4cd06ffe349 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Fri, 22 Sep 2023 04:36:55 +0000 Subject: [PATCH 053/296] Sep 21, 2023, 9:36 PM --- tableauserverclient/server/endpoint/favorites_endpoint.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index ee2ba9041..7e32a9d32 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -1,12 +1,10 @@ from .endpoint import Endpoint, api from tableauserverclient.server import RequestFactory -from tableauserverclient.models import FavoriteItem +from tableauserverclient.models import FavoriteItem, UserItem, Resource from typing import Optional, TYPE_CHECKING - -if TYPE_CHECKING: - from ...models import DatasourceItem, FlowItem, ProjectItem, UserItem, ViewItem, WorkbookItem - from ..request_options import RequestOptions +from ..request_options import RequestOptions +from ...models import DatasourceItem, FlowItem, ProjectItem, ViewItem, WorkbookItem, TableauItem, MetricItem from tableauserverclient.helpers.logging import logger From c1e17ce9b109b1ece098f86c721e58a9eb1e6f98 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Fri, 22 Sep 2023 05:42:45 +0000 Subject: [PATCH 054/296] Sep 21, 2023, 10:42 PM --- .../server/endpoint/favorites_endpoint.py | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index 7e32a9d32..f82b1b3d5 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -1,12 +1,20 @@ from .endpoint import Endpoint, api -from tableauserverclient.server import RequestFactory -from tableauserverclient.models import FavoriteItem, UserItem, Resource - -from typing import Optional, TYPE_CHECKING -from ..request_options import RequestOptions -from ...models import DatasourceItem, FlowItem, ProjectItem, ViewItem, WorkbookItem, TableauItem, MetricItem - +from requests import Response from tableauserverclient.helpers.logging import logger +from tableauserverclient.models import ( + DatasourceItem, + FavoriteItem, + FlowItem, + MetricItem, + ProjectItem, + Resource, + TableauItem, + UserItem, + ViewItem, + WorkbookItem, +) +from tableauserverclient.server import RequestFactory, RequestOptions +from typing import Optional class Favorites(Endpoint): From 341dcd27bfa5eadfd852486ed2e812e713239941 Mon Sep 17 00:00:00 2001 From: Jac Date: Fri, 22 Sep 2023 11:08:14 -0700 Subject: [PATCH 055/296] 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 2f6a34322fea14fbccd1c093e521be2236ebfb81 Mon Sep 17 00:00:00 2001 From: Jac Date: Mon, 25 Sep 2023 16:17:47 -0700 Subject: [PATCH 056/296] Code coverage and pretty printing (#1283) * implement str and repr for a bunch more classes * also: versioning for JWT, user-impersonation (cherry picked from commit 4887a62f9cb874a69098e88f73a6e8edcd1ae78e) * fix code coverage action * use reflection to find all models for comprehensive testing. --------- Co-authored-by: Lee Graber --- .github/workflows/code-coverage.yml | 2 +- README.md | 2 +- pyproject.toml | 3 +- tableauserverclient/__init__.py | 43 ++++++++++++- tableauserverclient/models/__init__.py | 2 +- tableauserverclient/models/column_item.py | 3 + .../models/connection_credentials.py | 7 +++ .../models/data_acceleration_report_item.py | 3 + tableauserverclient/models/group_item.py | 5 +- tableauserverclient/models/interval_item.py | 12 ++++ tableauserverclient/models/job_item.py | 11 +++- tableauserverclient/models/metric_item.py | 5 +- tableauserverclient/models/pagination_item.py | 3 + .../models/permissions_item.py | 11 ++-- tableauserverclient/models/schedule_item.py | 5 +- .../models/server_info_item.py | 2 +- tableauserverclient/models/site_item.py | 3 + tableauserverclient/models/table_item.py | 6 ++ tableauserverclient/models/tableau_auth.py | 21 +++++-- tableauserverclient/models/user_item.py | 5 +- tableauserverclient/models/view_item.py | 5 +- tableauserverclient/models/workbook_item.py | 5 +- .../server/endpoint/auth_endpoint.py | 7 ++- test/models/_models.py | 49 +++++++-------- test/models/test_repr.py | 63 +++++++++++-------- test/test_group_model.py | 10 --- 26 files changed, 205 insertions(+), 88 deletions(-) diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index d858c3389..2549773c0 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -30,7 +30,7 @@ jobs: # https://github.com/marketplace/actions/pytest-coverage-comment - name: Generate coverage report - run: pytest --junitxml=pytest.xml --cov=tableauserverclient tests/ | tee pytest-coverage.txt + run: pytest --junitxml=pytest.xml --cov=tableauserverclient test/ | tee pytest-coverage.txt - name: Comment on pull request with coverage uses: MishaKav/pytest-coverage-comment@main diff --git a/README.md b/README.md index 71bf9b023..ab6a66fae 100644 --- a/README.md +++ b/README.md @@ -19,4 +19,4 @@ For more information on installing and using TSC, see the documentation: ## License -[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python?ref=badge_large) \ No newline at end of file +[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python?ref=badge_large) diff --git a/pyproject.toml b/pyproject.toml index 717ca7cde..8ec6df4d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,8 @@ 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-subtests", "requests-mock>=1.0,<2.0"] +test = ["argparse", "black==23.7", "mock", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", + "requests-mock>=1.0,<2.0"] [tool.black] line-length = 120 diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 03e484372..c5c3c1922 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -1,6 +1,47 @@ from ._version import get_versions from .namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE -from .models import * +from .models import ( + BackgroundJobItem, + ColumnItem, + ConnectionCredentials, + ConnectionItem, + CustomViewItem, + DQWItem, + DailyInterval, + DataAlertItem, + DatabaseItem, + 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, +) from .server import ( CSVRequestOptions, ExcelRequestOptions, diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index b4a52f753..03d692583 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -31,7 +31,7 @@ from .site_item import SiteItem from .subscription_item import SubscriptionItem from .table_item import TableItem -from .tableau_auth import Credentials, TableauAuth, PersonalAccessTokenAuth +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 diff --git a/tableauserverclient/models/column_item.py b/tableauserverclient/models/column_item.py index dbf200d21..df936e315 100644 --- a/tableauserverclient/models/column_item.py +++ b/tableauserverclient/models/column_item.py @@ -9,6 +9,9 @@ def __init__(self, name, description=None): self.description = description self.name = name + def __repr__(self): + return f"<{self.__class__.__name__} {self._id} {self.name} {self.description}>" + @property def id(self): return self._id diff --git a/tableauserverclient/models/connection_credentials.py b/tableauserverclient/models/connection_credentials.py index db65de0ad..d61bbb751 100644 --- a/tableauserverclient/models/connection_credentials.py +++ b/tableauserverclient/models/connection_credentials.py @@ -15,6 +15,13 @@ def __init__(self, name, password, embed=True, oauth=False): self.embed = embed self.oauth = oauth + def __repr__(self): + if self.password: + print = "redacted" + else: + print = "None" + return f"<{self.__class__.__name__} name={self.name} password={print} embed={self.embed} oauth={self.oauth} >" + @property def embed(self): return self._embed diff --git a/tableauserverclient/models/data_acceleration_report_item.py b/tableauserverclient/models/data_acceleration_report_item.py index 3c1d6ed40..7424e6b95 100644 --- a/tableauserverclient/models/data_acceleration_report_item.py +++ b/tableauserverclient/models/data_acceleration_report_item.py @@ -46,6 +46,9 @@ def avg_non_accelerated_plt(self): def __init__(self, comparison_records): self._comparison_records = comparison_records + def __repr__(self): + return f"<(deprecated)DataAccelerationReportItem site={self.site} sheet={sheet_uri}>" + @property def comparison_records(self): return self._comparison_records diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index 96c3ae675..6c8f7eb01 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -26,11 +26,9 @@ def __init__(self, name=None, domain_name=None) -> None: self.name: Optional[str] = name self.domain_name: Optional[str] = domain_name - def __str__(self): + def __repr__(self): return "{}({!r})".format(self.__class__.__name__, self.__dict__) - __repr__ = __str__ - @property def domain_name(self) -> Optional[str]: return self._domain_name @@ -48,7 +46,6 @@ def name(self) -> Optional[str]: return self._name @name.setter - @property_not_empty def name(self, value: str) -> None: self._name = value diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index 25b6d09d7..02b57591b 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -31,6 +31,9 @@ def __init__(self, start_time, end_time, interval_value): self.end_time = end_time self.interval = interval_value + def __repr__(self): + return f"<{self.__class__.__name__} start={self.start_time} end={self.end_time} interval={self.interval}>" + @property def _frequency(self): return IntervalItem.Frequency.Hourly @@ -86,6 +89,9 @@ def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values + def __repr__(self): + return f"<{self.__class__.__name__} start={self.start_time} interval={self.interval}>" + @property def _frequency(self): return IntervalItem.Frequency.Daily @@ -114,6 +120,9 @@ def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values + def __repr__(self): + return f"<{self.__class__.__name__} start={self.start_time} interval={self.interval}>" + @property def _frequency(self): return IntervalItem.Frequency.Weekly @@ -148,6 +157,9 @@ def __init__(self, start_time, interval_value): self.start_time = start_time self.interval = str(interval_value) + def __repr__(self): + return f"<{self.__class__.__name__} start={self.start_time} interval={self.interval}>" + @property def _frequency(self): return IntervalItem.Frequency.Monthly diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 5a2636246..61e7a8d18 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -117,12 +117,15 @@ def flow_run(self, value): def updated_at(self) -> Optional[datetime.datetime]: return self._updated_at - def __repr__(self): + def __str__(self): return ( "".format(**self.__dict__) ) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @classmethod def from_response(cls, xml, ns) -> List["JobItem"]: parsed_response = fromstring(xml) @@ -202,6 +205,12 @@ def __init__( self._title = title self._subtitle = subtitle + def __str__(self): + return f"<{self.__class__.name} {self._id} {self._type}>" + + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @property def id(self) -> str: return self._id diff --git a/tableauserverclient/models/metric_item.py b/tableauserverclient/models/metric_item.py index e390d2c4d..d8ba8e825 100644 --- a/tableauserverclient/models/metric_item.py +++ b/tableauserverclient/models/metric_item.py @@ -115,9 +115,12 @@ def view_id(self, value: Optional[str]) -> None: def _set_permissions(self, permissions): self._permissions = permissions - def __repr__(self): + def __str__(self): return "".format(**vars(self)) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @classmethod def from_response( cls, diff --git a/tableauserverclient/models/pagination_item.py b/tableauserverclient/models/pagination_item.py index 2cb89dc5e..8cebd1c86 100644 --- a/tableauserverclient/models/pagination_item.py +++ b/tableauserverclient/models/pagination_item.py @@ -7,6 +7,9 @@ def __init__(self): self._page_size = None self._total_available = None + def __repr__(self): + return f"" + @property def page_number(self) -> int: return self._page_number diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 1602b077f..d2b2227db 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -1,4 +1,3 @@ -import logging import xml.etree.ElementTree as ET from typing import Dict, List, Optional @@ -17,6 +16,9 @@ class Mode: Allow = "Allow" Deny = "Deny" + def __repr__(self): + return "" + class Capability: AddComment = "AddComment" ChangeHierarchy = "ChangeHierarchy" @@ -39,17 +41,18 @@ class Capability: CreateRefreshMetrics = "CreateRefreshMetrics" SaveAs = "SaveAs" + def __repr__(self): + return "" + class PermissionsRule(object): def __init__(self, grantee: ResourceReference, capabilities: Dict[str, str]) -> None: self.grantee = grantee self.capabilities = capabilities - def __str__(self): + def __repr__(self): return "".format(self.grantee, self.capabilities) - __repr__ = __str__ - @classmethod def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: parsed_response = fromstring(resp) diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index edfd0fe70..dc0eca948 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -48,9 +48,12 @@ def __init__(self, name: str, priority: int, schedule_type: str, execution_order self.priority: int = priority self.schedule_type: str = schedule_type - def __repr__(self): + def __str__(self): return ''.format(**vars(self)) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @property def created_at(self) -> Optional[datetime]: return self._created_at diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py index b180665dd..57fc51af9 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -12,7 +12,7 @@ def __init__(self, product_version, build_number, rest_api_version): self._build_number = build_number self._rest_api_version = rest_api_version - def __str__(self): + def __repr__(self): return ( "ServerInfoItem: [product version: " + self._product_version diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index 813e812af..b651e5773 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -39,6 +39,9 @@ def __str__(self): + ">" ) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + class AdminMode: ContentAndUsers: str = "ContentAndUsers" ContentOnly: str = "ContentOnly" diff --git a/tableauserverclient/models/table_item.py b/tableauserverclient/models/table_item.py index 7fbaa32d2..f9df8a8f3 100644 --- a/tableauserverclient/models/table_item.py +++ b/tableauserverclient/models/table_item.py @@ -19,6 +19,12 @@ def __init__(self, name, description=None): self._columns = None self._data_quality_warnings = None + def __str__(self): + return f"<{self.__class__.__name__} {self._id} {self._name} >" + + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @property def permissions(self): if self._permissions is None: diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 30639d09b..9aca206d7 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -43,7 +43,11 @@ def credentials(self): return {"name": self.username, "password": self.password} def __repr__(self): - return "".format(self.username, "") + if self.user_id_to_impersonate: + uid = f", user_id_to_impersonate=f{self.user_id_to_impersonate}" + else: + uid = "" + return f"" @property def site(self): @@ -56,6 +60,7 @@ def site(self, value): self.site_id = value +# 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): if personal_access_token is None or token_name is None: @@ -72,13 +77,19 @@ def credentials(self): } def __repr__(self): - return "(site={})".format( - self.token_name, self.personal_access_token[:2] + "...", self.site_id + if self.user_id_to_impersonate: + uid = f", user_id_to_impersonate=f{self.user_id_to_impersonate}" + else: + uid = "" + return ( + f"" ) +# A standard JWT generated specifically for Tableau class JWTAuth(Credentials): - def __init__(self, jwt=None, site_id=None, user_id_to_impersonate=None): + def __init__(self, jwt: str, 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) @@ -93,4 +104,4 @@ def __repr__(self): 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})>" + return f"<{self.__class__.__qualname__} jwt={self.jwt[:5]}... (site={self.site_id}{uid})>" diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index a12f4b557..fe659575a 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -67,10 +67,13 @@ def __init__( return None - def __repr__(self) -> str: + def __str__(self) -> str: str_site_role = self.site_role or "None" return "".format(self.id, self.name, str_site_role) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @property def auth_setting(self) -> Optional[str]: return self._auth_setting diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index ef1fb0e52..90cff490b 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -32,11 +32,14 @@ def __init__(self) -> None: self._permissions: Optional[Callable[[], List[PermissionsRule]]] = None self.tags: Set[str] = set() - def __repr__(self): + def __str__(self): return "".format( self._id, self.name, self.content_url, self.project_id ) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + def _set_preview_image(self, preview_image): self._preview_image = preview_image diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 16e05498b..86a9a2f18 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -53,11 +53,14 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, return None - def __repr__(self): + def __str__(self): return "".format( self._id, self.name, self.content_url, self.project_id ) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @property def connections(self) -> List[ConnectionItem]: if self._connections is None: diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 2025de5fb..0b6bac0c9 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -66,9 +66,14 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr: logger.info("Signed into {0} as user with id {1}".format(self.parent_srv.server_address, user_id)) return Auth.contextmgr(self.sign_out) + # We use the same request that username/password login uses for all auth types. + # The distinct methods are mostly useful for explicitly showing api version support for each auth type @api(version="3.6") 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="3.17") + def sign_in_with_json_web_token(self, auth_req: "Credentials") -> contextmgr: return self.sign_in(auth_req) @api(version="2.0") diff --git a/test/models/_models.py b/test/models/_models.py index a1630da9c..59011c6c3 100644 --- a/test/models/_models.py +++ b/test/models/_models.py @@ -1,61 +1,58 @@ from tableauserverclient import * -# mmm. why aren't these available in the tsc namespace? +# TODO why aren't these available in the tsc namespace? Probably a bug. from tableauserverclient.models import ( DataAccelerationReportItem, - FavoriteItem, Credentials, ServerInfoItem, Resource, TableauItem, - plural_type, ) def get_defined_models(): - # not clever: copied from tsc/models/__init__.py + # nothing clever here: list was manually copied from tsc/models/__init__.py return [ - ColumnItem, - ConnectionCredentials, + BackgroundJobItem, ConnectionItem, DataAccelerationReportItem, DataAlertItem, - DatabaseItem, DatasourceItem, - DQWItem, - UnpopulatedPropertyError, - FavoriteItem, FlowItem, - FlowRunItem, GroupItem, - IntervalItem, - DailyInterval, - WeeklyInterval, - MonthlyInterval, - HourlyInterval, JobItem, - BackgroundJobItem, MetricItem, - PaginationItem, PermissionsRule, - Permission, ProjectItem, RevisionItem, ScheduleItem, - ServerInfoItem, - SiteItem, SubscriptionItem, - TableItem, Credentials, + JWTAuth, TableauAuth, PersonalAccessTokenAuth, - Resource, - TableauItem, - plural_type, - Target, + ServerInfoItem, + SiteItem, TaskItem, UserItem, ViewItem, WebhookItem, WorkbookItem, + PaginationItem, + Permission.Mode, + Permission.Capability, + DailyInterval, + WeeklyInterval, + MonthlyInterval, + HourlyInterval, + TableItem, + Target, + ] + + +def get_unimplemented_models(): + return [ + FavoriteItem, # no repr because there is no state + Resource, # list of type names + TableauItem, # should be an interface ] diff --git a/test/models/test_repr.py b/test/models/test_repr.py index d21e4bc4a..92d11978f 100644 --- a/test/models/test_repr.py +++ b/test/models/test_repr.py @@ -1,40 +1,51 @@ -import pytest +import inspect from unittest import TestCase import _models # type: ignore # did not set types for this +import tableauserverclient as TSC +from typing import Any -# ensure that all models have a __repr__ method implemented -class TestAllModels(TestCase): - """ - ColumnItem wrapper_descriptor - ConnectionCredentials wrapper_descriptor - DataAccelerationReportItem wrapper_descriptor - DatabaseItem wrapper_descriptor - DQWItem wrapper_descriptor - UnpopulatedPropertyError wrapper_descriptor - FavoriteItem wrapper_descriptor - FlowRunItem wrapper_descriptor - IntervalItem wrapper_descriptor - DailyInterval wrapper_descriptor - WeeklyInterval wrapper_descriptor - MonthlyInterval wrapper_descriptor - HourlyInterval wrapper_descriptor - BackgroundJobItem wrapper_descriptor - PaginationItem wrapper_descriptor - Permission wrapper_descriptor - ServerInfoItem wrapper_descriptor - SiteItem wrapper_descriptor - TableItem wrapper_descriptor - Resource wrapper_descriptor - """ +# ensure that all models that don't need parameters can be instantiated +# todo.... +def instantiate_class(name: str, obj: Any): + # Get the constructor (init) of the class + constructor = getattr(obj, "__init__", None) + if constructor: + # Get the parameters of the constructor (excluding 'self') + parameters = inspect.signature(constructor).parameters.values() + required_parameters = [ + param for param in parameters if param.default == inspect.Parameter.empty and param.name != "self" + ] + if required_parameters: + print(f"Class '{name}' requires the following parameters for instantiation:") + for param in required_parameters: + print(f"- {param.name}") + else: + print(f"Class '{name}' does not require any parameters for instantiation.") + # Instantiate the class + instance = obj() + print(f"Instantiated: {name} -> {instance}") + else: + print(f"Class '{name}' does not have a constructor (__init__ method).") + +class TestAllModels(TestCase): # not all models have __repr__ yet: see above list - @pytest.mark.xfail() def test_repr_is_implemented(self): m = _models.get_defined_models() for model in m: with self.subTest(model.__name__, model=model): print(model.__name__, type(model.__repr__).__name__) self.assertEqual(type(model.__repr__).__name__, "function") + + # 2 - Iterate through the objects in the module + def test_by_reflection(self): + for class_name, obj in inspect.getmembers(TSC, is_concrete): + with self.subTest(class_name, obj=obj): + instantiate_class(class_name, obj) + + +def is_concrete(obj: Any): + return inspect.isclass(obj) and not inspect.isabstract(obj) diff --git a/test/test_group_model.py b/test/test_group_model.py index 6b79dc18a..659a3611f 100644 --- a/test/test_group_model.py +++ b/test/test_group_model.py @@ -4,16 +4,6 @@ class GroupModelTests(unittest.TestCase): - def test_invalid_name(self): - self.assertRaises(ValueError, TSC.GroupItem, None) - self.assertRaises(ValueError, TSC.GroupItem, "") - group = TSC.GroupItem("grp") - with self.assertRaises(ValueError): - group.name = None - - with self.assertRaises(ValueError): - group.name = "" - def test_invalid_minimum_site_role(self): group = TSC.GroupItem("grp") with self.assertRaises(ValueError): From 6d8bbe82007969084d51218fee87574e85d08c6f Mon Sep 17 00:00:00 2001 From: Jac Date: Thu, 28 Sep 2023 18:13:52 -0700 Subject: [PATCH 057/296] update publish action (#1286) * 0.27 (#1272) --- .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 From d9cc13460f450bc7e505fab32c4e0b640b120986 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Oct 2023 19:04:31 -0700 Subject: [PATCH 058/296] Bump urllib3 from 2.0.4 to 2.0.6 (#1287) * Bump urllib3 from 2.0.4 to 2.0.6 Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.0.4 to 2.0.6. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/2.0.4...2.0.6) --- updated-dependencies: - dependency-name: urllib3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8ec6df4d5..12f4fb8c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ '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 + 'urllib3==2.0.6', # latest as at 7/31/23 ] requires-python = ">=3.7" classifiers = [ From 72eb3c8500193e4f20defa20c8a6f8bbf34b2f43 Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 4 Oct 2023 00:33:03 -0700 Subject: [PATCH 059/296] 0.28 - JWT Auth (#1288) * Code coverage and pretty printing (#1283) * implement str and repr for a bunch more classes * also: JWT, user-impersonation (cherry picked from commit 4887a62f9cb874a69098e88f73a6e8edcd1ae78e) * fix code coverage action * use reflection to find all models for comprehensive testing. --------- Co-authored-by: Lee Graber * update publish action (#1286) * 0.27 (#1272) * Bump urllib3 from 2.0.4 to 2.0.6 (#1287) * Bump urllib3 from 2.0.4 to 2.0.6 Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.0.4 to 2.0.6. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/2.0.4...2.0.6) --- updated-dependencies: - dependency-name: urllib3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --------- Signed-off-by: dependabot[bot] Co-authored-by: Lee Graber Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/code-coverage.yml | 2 +- .github/workflows/publish-pypi.yml | 2 +- README.md | 2 +- pyproject.toml | 5 +- tableauserverclient/__init__.py | 43 ++++++++++++- tableauserverclient/models/__init__.py | 2 +- tableauserverclient/models/column_item.py | 3 + .../models/connection_credentials.py | 7 +++ .../models/data_acceleration_report_item.py | 3 + tableauserverclient/models/group_item.py | 5 +- tableauserverclient/models/interval_item.py | 12 ++++ tableauserverclient/models/job_item.py | 11 +++- tableauserverclient/models/metric_item.py | 5 +- tableauserverclient/models/pagination_item.py | 3 + .../models/permissions_item.py | 11 ++-- tableauserverclient/models/schedule_item.py | 5 +- .../models/server_info_item.py | 2 +- tableauserverclient/models/site_item.py | 3 + tableauserverclient/models/table_item.py | 6 ++ tableauserverclient/models/tableau_auth.py | 21 +++++-- tableauserverclient/models/user_item.py | 5 +- tableauserverclient/models/view_item.py | 5 +- tableauserverclient/models/workbook_item.py | 5 +- .../server/endpoint/auth_endpoint.py | 7 ++- test/models/_models.py | 49 +++++++-------- test/models/test_repr.py | 63 +++++++++++-------- test/test_group_model.py | 10 --- 27 files changed, 207 insertions(+), 90 deletions(-) diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index d858c3389..2549773c0 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -30,7 +30,7 @@ jobs: # https://github.com/marketplace/actions/pytest-coverage-comment - name: Generate coverage report - run: pytest --junitxml=pytest.xml --cov=tableauserverclient tests/ | tee pytest-coverage.txt + run: pytest --junitxml=pytest.xml --cov=tableauserverclient test/ | tee pytest-coverage.txt - name: Comment on pull request with coverage uses: MishaKav/pytest-coverage-comment@main 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 diff --git a/README.md b/README.md index 71bf9b023..ab6a66fae 100644 --- a/README.md +++ b/README.md @@ -19,4 +19,4 @@ For more information on installing and using TSC, see the documentation: ## License -[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python?ref=badge_large) \ No newline at end of file +[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python?ref=badge_large) diff --git a/pyproject.toml b/pyproject.toml index 717ca7cde..12f4fb8c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ '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 + 'urllib3==2.0.6', # latest as at 7/31/23 ] requires-python = ">=3.7" classifiers = [ @@ -31,7 +31,8 @@ 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-subtests", "requests-mock>=1.0,<2.0"] +test = ["argparse", "black==23.7", "mock", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", + "requests-mock>=1.0,<2.0"] [tool.black] line-length = 120 diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 03e484372..c5c3c1922 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -1,6 +1,47 @@ from ._version import get_versions from .namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE -from .models import * +from .models import ( + BackgroundJobItem, + ColumnItem, + ConnectionCredentials, + ConnectionItem, + CustomViewItem, + DQWItem, + DailyInterval, + DataAlertItem, + DatabaseItem, + 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, +) from .server import ( CSVRequestOptions, ExcelRequestOptions, diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index b4a52f753..03d692583 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -31,7 +31,7 @@ from .site_item import SiteItem from .subscription_item import SubscriptionItem from .table_item import TableItem -from .tableau_auth import Credentials, TableauAuth, PersonalAccessTokenAuth +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 diff --git a/tableauserverclient/models/column_item.py b/tableauserverclient/models/column_item.py index dbf200d21..df936e315 100644 --- a/tableauserverclient/models/column_item.py +++ b/tableauserverclient/models/column_item.py @@ -9,6 +9,9 @@ def __init__(self, name, description=None): self.description = description self.name = name + def __repr__(self): + return f"<{self.__class__.__name__} {self._id} {self.name} {self.description}>" + @property def id(self): return self._id diff --git a/tableauserverclient/models/connection_credentials.py b/tableauserverclient/models/connection_credentials.py index db65de0ad..d61bbb751 100644 --- a/tableauserverclient/models/connection_credentials.py +++ b/tableauserverclient/models/connection_credentials.py @@ -15,6 +15,13 @@ def __init__(self, name, password, embed=True, oauth=False): self.embed = embed self.oauth = oauth + def __repr__(self): + if self.password: + print = "redacted" + else: + print = "None" + return f"<{self.__class__.__name__} name={self.name} password={print} embed={self.embed} oauth={self.oauth} >" + @property def embed(self): return self._embed diff --git a/tableauserverclient/models/data_acceleration_report_item.py b/tableauserverclient/models/data_acceleration_report_item.py index 3c1d6ed40..7424e6b95 100644 --- a/tableauserverclient/models/data_acceleration_report_item.py +++ b/tableauserverclient/models/data_acceleration_report_item.py @@ -46,6 +46,9 @@ def avg_non_accelerated_plt(self): def __init__(self, comparison_records): self._comparison_records = comparison_records + def __repr__(self): + return f"<(deprecated)DataAccelerationReportItem site={self.site} sheet={sheet_uri}>" + @property def comparison_records(self): return self._comparison_records diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index 96c3ae675..6c8f7eb01 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -26,11 +26,9 @@ def __init__(self, name=None, domain_name=None) -> None: self.name: Optional[str] = name self.domain_name: Optional[str] = domain_name - def __str__(self): + def __repr__(self): return "{}({!r})".format(self.__class__.__name__, self.__dict__) - __repr__ = __str__ - @property def domain_name(self) -> Optional[str]: return self._domain_name @@ -48,7 +46,6 @@ def name(self) -> Optional[str]: return self._name @name.setter - @property_not_empty def name(self, value: str) -> None: self._name = value diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index 25b6d09d7..02b57591b 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -31,6 +31,9 @@ def __init__(self, start_time, end_time, interval_value): self.end_time = end_time self.interval = interval_value + def __repr__(self): + return f"<{self.__class__.__name__} start={self.start_time} end={self.end_time} interval={self.interval}>" + @property def _frequency(self): return IntervalItem.Frequency.Hourly @@ -86,6 +89,9 @@ def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values + def __repr__(self): + return f"<{self.__class__.__name__} start={self.start_time} interval={self.interval}>" + @property def _frequency(self): return IntervalItem.Frequency.Daily @@ -114,6 +120,9 @@ def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values + def __repr__(self): + return f"<{self.__class__.__name__} start={self.start_time} interval={self.interval}>" + @property def _frequency(self): return IntervalItem.Frequency.Weekly @@ -148,6 +157,9 @@ def __init__(self, start_time, interval_value): self.start_time = start_time self.interval = str(interval_value) + def __repr__(self): + return f"<{self.__class__.__name__} start={self.start_time} interval={self.interval}>" + @property def _frequency(self): return IntervalItem.Frequency.Monthly diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 5a2636246..61e7a8d18 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -117,12 +117,15 @@ def flow_run(self, value): def updated_at(self) -> Optional[datetime.datetime]: return self._updated_at - def __repr__(self): + def __str__(self): return ( "".format(**self.__dict__) ) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @classmethod def from_response(cls, xml, ns) -> List["JobItem"]: parsed_response = fromstring(xml) @@ -202,6 +205,12 @@ def __init__( self._title = title self._subtitle = subtitle + def __str__(self): + return f"<{self.__class__.name} {self._id} {self._type}>" + + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @property def id(self) -> str: return self._id diff --git a/tableauserverclient/models/metric_item.py b/tableauserverclient/models/metric_item.py index e390d2c4d..d8ba8e825 100644 --- a/tableauserverclient/models/metric_item.py +++ b/tableauserverclient/models/metric_item.py @@ -115,9 +115,12 @@ def view_id(self, value: Optional[str]) -> None: def _set_permissions(self, permissions): self._permissions = permissions - def __repr__(self): + def __str__(self): return "".format(**vars(self)) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @classmethod def from_response( cls, diff --git a/tableauserverclient/models/pagination_item.py b/tableauserverclient/models/pagination_item.py index 2cb89dc5e..8cebd1c86 100644 --- a/tableauserverclient/models/pagination_item.py +++ b/tableauserverclient/models/pagination_item.py @@ -7,6 +7,9 @@ def __init__(self): self._page_size = None self._total_available = None + def __repr__(self): + return f"" + @property def page_number(self) -> int: return self._page_number diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 1602b077f..d2b2227db 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -1,4 +1,3 @@ -import logging import xml.etree.ElementTree as ET from typing import Dict, List, Optional @@ -17,6 +16,9 @@ class Mode: Allow = "Allow" Deny = "Deny" + def __repr__(self): + return "" + class Capability: AddComment = "AddComment" ChangeHierarchy = "ChangeHierarchy" @@ -39,17 +41,18 @@ class Capability: CreateRefreshMetrics = "CreateRefreshMetrics" SaveAs = "SaveAs" + def __repr__(self): + return "" + class PermissionsRule(object): def __init__(self, grantee: ResourceReference, capabilities: Dict[str, str]) -> None: self.grantee = grantee self.capabilities = capabilities - def __str__(self): + def __repr__(self): return "".format(self.grantee, self.capabilities) - __repr__ = __str__ - @classmethod def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: parsed_response = fromstring(resp) diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index edfd0fe70..dc0eca948 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -48,9 +48,12 @@ def __init__(self, name: str, priority: int, schedule_type: str, execution_order self.priority: int = priority self.schedule_type: str = schedule_type - def __repr__(self): + def __str__(self): return ''.format(**vars(self)) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @property def created_at(self) -> Optional[datetime]: return self._created_at diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py index b180665dd..57fc51af9 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -12,7 +12,7 @@ def __init__(self, product_version, build_number, rest_api_version): self._build_number = build_number self._rest_api_version = rest_api_version - def __str__(self): + def __repr__(self): return ( "ServerInfoItem: [product version: " + self._product_version diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index 813e812af..b651e5773 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -39,6 +39,9 @@ def __str__(self): + ">" ) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + class AdminMode: ContentAndUsers: str = "ContentAndUsers" ContentOnly: str = "ContentOnly" diff --git a/tableauserverclient/models/table_item.py b/tableauserverclient/models/table_item.py index 7fbaa32d2..f9df8a8f3 100644 --- a/tableauserverclient/models/table_item.py +++ b/tableauserverclient/models/table_item.py @@ -19,6 +19,12 @@ def __init__(self, name, description=None): self._columns = None self._data_quality_warnings = None + def __str__(self): + return f"<{self.__class__.__name__} {self._id} {self._name} >" + + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @property def permissions(self): if self._permissions is None: diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 30639d09b..9aca206d7 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -43,7 +43,11 @@ def credentials(self): return {"name": self.username, "password": self.password} def __repr__(self): - return "".format(self.username, "") + if self.user_id_to_impersonate: + uid = f", user_id_to_impersonate=f{self.user_id_to_impersonate}" + else: + uid = "" + return f"" @property def site(self): @@ -56,6 +60,7 @@ def site(self, value): self.site_id = value +# 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): if personal_access_token is None or token_name is None: @@ -72,13 +77,19 @@ def credentials(self): } def __repr__(self): - return "(site={})".format( - self.token_name, self.personal_access_token[:2] + "...", self.site_id + if self.user_id_to_impersonate: + uid = f", user_id_to_impersonate=f{self.user_id_to_impersonate}" + else: + uid = "" + return ( + f"" ) +# A standard JWT generated specifically for Tableau class JWTAuth(Credentials): - def __init__(self, jwt=None, site_id=None, user_id_to_impersonate=None): + def __init__(self, jwt: str, 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) @@ -93,4 +104,4 @@ def __repr__(self): 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})>" + return f"<{self.__class__.__qualname__} jwt={self.jwt[:5]}... (site={self.site_id}{uid})>" diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index a12f4b557..fe659575a 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -67,10 +67,13 @@ def __init__( return None - def __repr__(self) -> str: + def __str__(self) -> str: str_site_role = self.site_role or "None" return "".format(self.id, self.name, str_site_role) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @property def auth_setting(self) -> Optional[str]: return self._auth_setting diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index ef1fb0e52..90cff490b 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -32,11 +32,14 @@ def __init__(self) -> None: self._permissions: Optional[Callable[[], List[PermissionsRule]]] = None self.tags: Set[str] = set() - def __repr__(self): + def __str__(self): return "".format( self._id, self.name, self.content_url, self.project_id ) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + def _set_preview_image(self, preview_image): self._preview_image = preview_image diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 16e05498b..86a9a2f18 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -53,11 +53,14 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, return None - def __repr__(self): + def __str__(self): return "".format( self._id, self.name, self.content_url, self.project_id ) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @property def connections(self) -> List[ConnectionItem]: if self._connections is None: diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 2025de5fb..0b6bac0c9 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -66,9 +66,14 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr: logger.info("Signed into {0} as user with id {1}".format(self.parent_srv.server_address, user_id)) return Auth.contextmgr(self.sign_out) + # We use the same request that username/password login uses for all auth types. + # The distinct methods are mostly useful for explicitly showing api version support for each auth type @api(version="3.6") 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="3.17") + def sign_in_with_json_web_token(self, auth_req: "Credentials") -> contextmgr: return self.sign_in(auth_req) @api(version="2.0") diff --git a/test/models/_models.py b/test/models/_models.py index a1630da9c..59011c6c3 100644 --- a/test/models/_models.py +++ b/test/models/_models.py @@ -1,61 +1,58 @@ from tableauserverclient import * -# mmm. why aren't these available in the tsc namespace? +# TODO why aren't these available in the tsc namespace? Probably a bug. from tableauserverclient.models import ( DataAccelerationReportItem, - FavoriteItem, Credentials, ServerInfoItem, Resource, TableauItem, - plural_type, ) def get_defined_models(): - # not clever: copied from tsc/models/__init__.py + # nothing clever here: list was manually copied from tsc/models/__init__.py return [ - ColumnItem, - ConnectionCredentials, + BackgroundJobItem, ConnectionItem, DataAccelerationReportItem, DataAlertItem, - DatabaseItem, DatasourceItem, - DQWItem, - UnpopulatedPropertyError, - FavoriteItem, FlowItem, - FlowRunItem, GroupItem, - IntervalItem, - DailyInterval, - WeeklyInterval, - MonthlyInterval, - HourlyInterval, JobItem, - BackgroundJobItem, MetricItem, - PaginationItem, PermissionsRule, - Permission, ProjectItem, RevisionItem, ScheduleItem, - ServerInfoItem, - SiteItem, SubscriptionItem, - TableItem, Credentials, + JWTAuth, TableauAuth, PersonalAccessTokenAuth, - Resource, - TableauItem, - plural_type, - Target, + ServerInfoItem, + SiteItem, TaskItem, UserItem, ViewItem, WebhookItem, WorkbookItem, + PaginationItem, + Permission.Mode, + Permission.Capability, + DailyInterval, + WeeklyInterval, + MonthlyInterval, + HourlyInterval, + TableItem, + Target, + ] + + +def get_unimplemented_models(): + return [ + FavoriteItem, # no repr because there is no state + Resource, # list of type names + TableauItem, # should be an interface ] diff --git a/test/models/test_repr.py b/test/models/test_repr.py index d21e4bc4a..92d11978f 100644 --- a/test/models/test_repr.py +++ b/test/models/test_repr.py @@ -1,40 +1,51 @@ -import pytest +import inspect from unittest import TestCase import _models # type: ignore # did not set types for this +import tableauserverclient as TSC +from typing import Any -# ensure that all models have a __repr__ method implemented -class TestAllModels(TestCase): - """ - ColumnItem wrapper_descriptor - ConnectionCredentials wrapper_descriptor - DataAccelerationReportItem wrapper_descriptor - DatabaseItem wrapper_descriptor - DQWItem wrapper_descriptor - UnpopulatedPropertyError wrapper_descriptor - FavoriteItem wrapper_descriptor - FlowRunItem wrapper_descriptor - IntervalItem wrapper_descriptor - DailyInterval wrapper_descriptor - WeeklyInterval wrapper_descriptor - MonthlyInterval wrapper_descriptor - HourlyInterval wrapper_descriptor - BackgroundJobItem wrapper_descriptor - PaginationItem wrapper_descriptor - Permission wrapper_descriptor - ServerInfoItem wrapper_descriptor - SiteItem wrapper_descriptor - TableItem wrapper_descriptor - Resource wrapper_descriptor - """ +# ensure that all models that don't need parameters can be instantiated +# todo.... +def instantiate_class(name: str, obj: Any): + # Get the constructor (init) of the class + constructor = getattr(obj, "__init__", None) + if constructor: + # Get the parameters of the constructor (excluding 'self') + parameters = inspect.signature(constructor).parameters.values() + required_parameters = [ + param for param in parameters if param.default == inspect.Parameter.empty and param.name != "self" + ] + if required_parameters: + print(f"Class '{name}' requires the following parameters for instantiation:") + for param in required_parameters: + print(f"- {param.name}") + else: + print(f"Class '{name}' does not require any parameters for instantiation.") + # Instantiate the class + instance = obj() + print(f"Instantiated: {name} -> {instance}") + else: + print(f"Class '{name}' does not have a constructor (__init__ method).") + +class TestAllModels(TestCase): # not all models have __repr__ yet: see above list - @pytest.mark.xfail() def test_repr_is_implemented(self): m = _models.get_defined_models() for model in m: with self.subTest(model.__name__, model=model): print(model.__name__, type(model.__repr__).__name__) self.assertEqual(type(model.__repr__).__name__, "function") + + # 2 - Iterate through the objects in the module + def test_by_reflection(self): + for class_name, obj in inspect.getmembers(TSC, is_concrete): + with self.subTest(class_name, obj=obj): + instantiate_class(class_name, obj) + + +def is_concrete(obj: Any): + return inspect.isclass(obj) and not inspect.isabstract(obj) diff --git a/test/test_group_model.py b/test/test_group_model.py index 6b79dc18a..659a3611f 100644 --- a/test/test_group_model.py +++ b/test/test_group_model.py @@ -4,16 +4,6 @@ class GroupModelTests(unittest.TestCase): - def test_invalid_name(self): - self.assertRaises(ValueError, TSC.GroupItem, None) - self.assertRaises(ValueError, TSC.GroupItem, "") - group = TSC.GroupItem("grp") - with self.assertRaises(ValueError): - group.name = None - - with self.assertRaises(ValueError): - group.name = "" - def test_invalid_minimum_site_role(self): group = TSC.GroupItem("grp") with self.assertRaises(ValueError): From eaa45d8b52b2047d19697ab1f185693975f1fb2e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Oct 2023 21:08:42 +0000 Subject: [PATCH 060/296] Bump urllib3 from 2.0.6 to 2.0.7 Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.0.6 to 2.0.7. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/2.0.6...2.0.7) --- updated-dependencies: - dependency-name: urllib3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 12f4fb8c1..9c35a42e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ '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.6', # latest as at 7/31/23 + 'urllib3==2.0.7', # latest as at 7/31/23 ] requires-python = ">=3.7" classifiers = [ From 3fefcfabdfc66819a6c8e6c1c66ea09a66c00dbe Mon Sep 17 00:00:00 2001 From: gregg Date: Thu, 19 Oct 2023 19:00:45 +0000 Subject: [PATCH 061/296] Fix for #1301 of duplicate default permission requests 1. logging to the root logger isn't correct 2. the log line calls fetch_call() which makes a server request 3. retuns the results of fetch_call() which is never used anywhere Removing these lines from _set_default_permissions makes it more functionally equivalent to the above _set_permissions --- tableauserverclient/models/project_item.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index e7254ab5d..4918f1a14 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -163,9 +163,6 @@ def _set_default_permissions(self, permissions, content_type): attr, permissions, ) - fetch_call = getattr(self, attr) - logging.getLogger().info({"type": attr, "value": fetch_call()}) - return fetch_call() @classmethod def from_response(cls, resp, ns) -> List["ProjectItem"]: From ca4f9bebff63d2e3d91a5abf638497b641628dab Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Thu, 9 Nov 2023 17:06:35 -0800 Subject: [PATCH 062/296] Allow check to continue even if MishaKav/pytest-coverage-comment fails Right now this action is failing with what appears to be this issue: https://github.com/MishaKav/pytest-coverage-comment/issues/68 It seems to be failing on PRs from outside contributors only, so making this change will let thoses PRs through while we sort this out. --- .github/workflows/code-coverage.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index 2549773c0..e153c1fc7 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -33,6 +33,7 @@ jobs: run: pytest --junitxml=pytest.xml --cov=tableauserverclient test/ | tee pytest-coverage.txt - name: Comment on pull request with coverage + continue-on-error: true uses: MishaKav/pytest-coverage-comment@main with: pytest-coverage-path: ./pytest-coverage.txt From 25a59d0f8f54fb872c068b2f14cf366dd3a18e76 Mon Sep 17 00:00:00 2001 From: Fumiya Suto Date: Mon, 13 Nov 2023 20:26:42 +0900 Subject: [PATCH 063/296] Fixed type annotation for workbook.refresh `workbook.refresh` is implemented to accept both `WorkbookItem` and `str` as arguments, but the type annotation describes it as receiving `str`, which can cause false warnings in static analysis. Since the documentation states that it receives `workbook_item`, the name of the argument is also changed from `workbook_id` to `workbook_item`. Issue: https://github.com/tableau/server-client-python/issues/1318 --- tableauserverclient/server/endpoint/workbooks_endpoint.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index a73b0f0d5..3c8efbe3b 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -88,8 +88,8 @@ def get_by_id(self, workbook_id: str) -> WorkbookItem: return WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="2.8") - def refresh(self, workbook_id: str) -> JobItem: - id_ = getattr(workbook_id, "id", workbook_id) + def refresh(self, workbook_item: Union[WorkbookItem, str]) -> JobItem: + id_ = getattr(workbook_item, "id", workbook_item) url = "{0}/{1}/refresh".format(self.baseurl, id_) empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) From 5b73beb145b9378cd7ef3c7a2c46a8214605a399 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Sat, 18 Nov 2023 11:01:45 -0800 Subject: [PATCH 064/296] Remove comment with fake password that was causing confusion --- tableauserverclient/helpers/strings.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tableauserverclient/helpers/strings.py b/tableauserverclient/helpers/strings.py index e51a6611a..75534103b 100644 --- a/tableauserverclient/helpers/strings.py +++ b/tableauserverclient/helpers/strings.py @@ -9,8 +9,6 @@ T = TypeVar("T", str, bytes) -# usage: _redact_any_type("") -# -> b" def _redact_any_type(xml: T, sensitive_word: T, replacement: T, encoding=None) -> T: try: root = fromstring(xml) From 082cec0b6a063117eee61ef43b08ecdde7d11e43 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Wed, 25 Oct 2023 20:13:58 -0500 Subject: [PATCH 065/296] Add all missing fields --- tableauserverclient/server/request_options.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 796f8add3..95233f8fc 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -37,35 +37,75 @@ class Operator: class Field: Args = "args" + AuthenticationType = "authenticationType" + Caption = "caption" + Channel = "channel" CompletedAt = "completedAt" + ConnectedWorkbookType = "connectedWorkbookType" + ConnectionTo = "connectionTo" + ConnectionType = "connectionType" ContentUrl = "contentUrl" CreatedAt = "createdAt" + DatabaseName = "databaseName" + DatabaseUserName = "databaseUserName" + Description = "description" + DisplayTabs = "displayTabs" DomainName = "domainName" DomainNickname = "domainNickname" + FavoritesTotal = "favoritesTotal" + Fields = "fields" + FlowId = "flowId" + FriendlyName = "friendlyName" + HasAlert = "hasAlert" + HasAlerts = "hasAlerts" + HasEmbeddedPassword = "hasEmbeddedPassword" + HasExtracts = "hasExtracts" HitsTotal = "hitsTotal" + Id = "id" + IsCertified = "isCertified" + IsConnectable = "isConnectable" + IsDefaultPort = "isDefaultPort" + IsHierarchical = "isHierarchical" IsLocal = "isLocal" + IsPublished = "isPublished" JobType = "jobType" LastLogin = "lastLogin" + Luid = "luid" MinimumSiteRole = "minimumSiteRole" Name = "name" Notes = "notes" + NotificationType = "notificationType" OwnerDomain = "ownerDomain" OwnerEmail = "ownerEmail" OwnerName = "ownerName" ParentProjectId = "parentProjectId" + Priority = "priority" Progress = "progress" + ProjectId = "projectId" ProjectName = "projectName" PublishSamples = "publishSamples" + ServerName = "serverName" + ServerPort = "serverPort" + SheetCount = "sheetCount" + SheetNumber = "sheetNumber" + SheetType = "sheetType" SiteRole = "siteRole" + Size = "size" StartedAt = "startedAt" Status = "status" + SubscriptionsTotal = "subscriptionsTotal" Subtitle = "subtitle" + TableName = "tableName" Tags = "tags" Title = "title" TopLevelProject = "topLevelProject" Type = "type" UpdatedAt = "updatedAt" UserCount = "userCount" + UserId = "userId" + ViewUrlName = "viewUrlName" + WorkbookDescription = "workbookDescription" + WorkbookName = "workbookName" class Direction: Desc = "desc" From 613334bed02ea2ab79a06d663f402a9af252f81f Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Sat, 14 Oct 2023 21:28:26 -0500 Subject: [PATCH 066/296] Make imports absolute --- tableauserverclient/models/task_item.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index 159869b07..2199861c7 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -1,8 +1,8 @@ from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime -from .schedule_item import ScheduleItem -from .target import Target +from tableauserverclient.models.schedule_item import ScheduleItem +from tableauserverclient.models.target import Target class TaskItem(object): From 20824143c79258994286d9351a7501b05ad4d0e9 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Sat, 14 Oct 2023 21:41:30 -0500 Subject: [PATCH 067/296] Add types to TaskItem --- tableauserverclient/models/task_item.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index 2199861c7..24d76fc19 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -1,3 +1,5 @@ +from typing import List + from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime @@ -44,7 +46,7 @@ def __repr__(self): ) @classmethod - def from_response(cls, xml, ns, task_type=Type.ExtractRefresh): + def from_response(cls, xml, ns, task_type=Type.ExtractRefresh) -> List["TaskItem"]: parsed_response = fromstring(xml) all_tasks_xml = parsed_response.findall(".//t:task/t:{}".format(task_type), namespaces=ns) @@ -94,7 +96,7 @@ def _parse_element(cls, element, ns): ) @staticmethod - def _translate_task_type(task_type): + def _translate_task_type(task_type: str) -> str: if task_type in TaskItem._TASK_TYPE_MAPPING: return TaskItem._TASK_TYPE_MAPPING[task_type] else: From 0a720e92cd09ed485855064d0c1d04c24685875b Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Sat, 14 Oct 2023 21:43:42 -0500 Subject: [PATCH 068/296] Make Tasks endpoint imports absolute --- tableauserverclient/server/endpoint/tasks_endpoint.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index 092597388..0d4b23027 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -1,7 +1,7 @@ import logging -from .endpoint import Endpoint, api -from .exceptions import MissingRequiredFieldError +from tableauserverclient.server.endpoint import Endpoint, api +from tableauserverclient.server.exceptions import MissingRequiredFieldError from tableauserverclient.models import TaskItem, PaginationItem from tableauserverclient.server import RequestFactory From cdbaf98f4803e48ff77c7fa7a5d72c8f9ee10623 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Sat, 14 Oct 2023 22:14:01 -0500 Subject: [PATCH 069/296] Add task test asset --- test/assets/tasks_without_schedule.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 test/assets/tasks_without_schedule.xml diff --git a/test/assets/tasks_without_schedule.xml b/test/assets/tasks_without_schedule.xml new file mode 100644 index 000000000..e669bf67f --- /dev/null +++ b/test/assets/tasks_without_schedule.xml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file From e65ca391fd929572ac5d91bd8de31fc7929460a1 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Sat, 14 Oct 2023 22:15:01 -0500 Subject: [PATCH 070/296] More typing of TaskItem --- tableauserverclient/models/task_item.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index 24d76fc19..96718f6d2 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -1,4 +1,5 @@ -from typing import List +from datetime import datetime +from typing import List, Optional from defusedxml.ElementTree import fromstring @@ -21,14 +22,14 @@ class Type: def __init__( self, - id_, - task_type, - priority, - consecutive_failed_count=0, - schedule_id=None, - schedule_item=None, - last_run_at=None, - target=None, + id_: str, + task_type: str, + priority: int, + consecutive_failed_count: int = 0, + schedule_id: Optional[str] = None, + schedule_item: Optional[str] = None, + last_run_at: Optional[datetime]=None, + target: Optional[Target] = None, ): self.id = id_ self.task_type = task_type @@ -39,7 +40,7 @@ def __init__( self.last_run_at = last_run_at self.target = target - def __repr__(self): + def __repr__(self) -> str: return ( "".format(**self.__dict__) From 600a0b7208392d177ce71072c12e2415b9b8aded Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Sat, 14 Oct 2023 22:15:39 -0500 Subject: [PATCH 071/296] Permit missing tasks missing schedule --- tableauserverclient/models/task_item.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index 96718f6d2..eae5948e3 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -65,8 +65,7 @@ def _parse_element(cls, element, ns): last_run_at_element = element.find(".//t:lastRunAt", namespaces=ns) schedule_item_list = ScheduleItem.from_element(element, ns) - if len(schedule_item_list) >= 1: - schedule_item = schedule_item_list[0] + schedule_item = next(iter(schedule_item_list), None) # according to the Tableau Server REST API documentation, # there should be only one of workbook or datasource @@ -90,7 +89,7 @@ def _parse_element(cls, element, ns): task_type, priority, consecutive_failed_count, - schedule_item.id, + schedule_item.id if schedule_item is not None else None, schedule_item, last_run_at, target, From b44d69e484abe61b8dde66b402e1b4152f65ce8b Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Sat, 14 Oct 2023 22:16:40 -0500 Subject: [PATCH 072/296] Fix import references --- tableauserverclient/server/endpoint/tasks_endpoint.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index 0d4b23027..92e0095c9 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -1,7 +1,7 @@ import logging -from tableauserverclient.server.endpoint import Endpoint, api -from tableauserverclient.server.exceptions import MissingRequiredFieldError +from tableauserverclient.server.endpoint.endpoint import Endpoint, api +from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError from tableauserverclient.models import TaskItem, PaginationItem from tableauserverclient.server import RequestFactory From f4280318ec8d31bdd2cc3347ae632ceb827a5b30 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Sat, 14 Oct 2023 22:17:15 -0500 Subject: [PATCH 073/296] Add test for missing schedule --- test/test_task.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/test/test_task.py b/test/test_task.py index 4eb2c02e2..4e0157dfd 100644 --- a/test/test_task.py +++ b/test/test_task.py @@ -1,6 +1,7 @@ import os import unittest from datetime import time +from pathlib import Path import requests_mock @@ -8,7 +9,7 @@ from tableauserverclient.datetime_helpers import parse_datetime from tableauserverclient.models.task_item import TaskItem -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") +TEST_ASSET_DIR = Path(__file__).parent / "assets" GET_XML_NO_WORKBOOK = os.path.join(TEST_ASSET_DIR, "tasks_no_workbook_or_datasource.xml") GET_XML_WITH_WORKBOOK = os.path.join(TEST_ASSET_DIR, "tasks_with_workbook.xml") @@ -17,6 +18,7 @@ GET_XML_DATAACCELERATION_TASK = os.path.join(TEST_ASSET_DIR, "tasks_with_dataacceleration_task.xml") GET_XML_RUN_NOW_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_run_now_response.xml") GET_XML_CREATE_TASK_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_create_extract_task.xml") +GET_XML_WITHOUT_SCHEDULE = TEST_ASSET_DIR / "tasks_without_schedule.xml" class TaskTests(unittest.TestCase): @@ -86,6 +88,15 @@ def test_get_task_with_schedule(self): self.assertEqual("workbook", task.target.type) self.assertEqual("b60b4efd-a6f7-4599-beb3-cb677e7abac1", task.schedule_id) + def test_get_task_without_schedule(self): + with requests_mock.mock() as m: + m.get(self.baseurl, text=GET_XML_WITHOUT_SCHEDULE.read_text()) + all_tasks, pagination_item = self.server.tasks.get() + + task = all_tasks[0] + self.assertEqual("c7a9327e-1cda-4504-b026-ddb43b976d1d", task.target.id) + self.assertEqual("datasource", task.target.type) + def test_delete(self): with requests_mock.mock() as m: m.delete(self.baseurl + "/c7a9327e-1cda-4504-b026-ddb43b976d1d", status_code=204) From 95d66973d8cc8599e71431f297b8838ae556c3a9 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Sat, 14 Oct 2023 22:17:37 -0500 Subject: [PATCH 074/296] Formatting --- tableauserverclient/models/task_item.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index eae5948e3..cb7eeec6f 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -28,7 +28,7 @@ def __init__( consecutive_failed_count: int = 0, schedule_id: Optional[str] = None, schedule_item: Optional[str] = None, - last_run_at: Optional[datetime]=None, + last_run_at: Optional[datetime] = None, target: Optional[Target] = None, ): self.id = id_ From 82ff83aca821b02091fa0035847eb179e83d607b Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Sat, 14 Oct 2023 22:39:46 -0500 Subject: [PATCH 075/296] Add type annotations --- tableauserverclient/models/task_item.py | 2 +- .../server/endpoint/tasks_endpoint.py | 18 ++++++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index cb7eeec6f..0ffc3bfab 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -27,7 +27,7 @@ def __init__( priority: int, consecutive_failed_count: int = 0, schedule_id: Optional[str] = None, - schedule_item: Optional[str] = None, + schedule_item: Optional[ScheduleItem] = None, last_run_at: Optional[datetime] = None, target: Optional[Target] = None, ): diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index 92e0095c9..383f0984e 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -1,4 +1,5 @@ import logging +from typing import List, Optional, Tuple, TYPE_CHECKING from tableauserverclient.server.endpoint.endpoint import Endpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError @@ -7,13 +8,16 @@ from tableauserverclient.helpers.logging import logger +if TYPE_CHECKING: + from tableauserverclient.server.request_options import RequestOptions + class Tasks(Endpoint): @property - def baseurl(self): + def baseurl(self) -> str: return "{0}/sites/{1}/tasks".format(self.parent_srv.baseurl, self.parent_srv.site_id) - def __normalize_task_type(self, task_type): + def __normalize_task_type(self, task_type: str) -> str: """ The word for extract refresh used in API URL is "extractRefreshes". It is different than the tag "extractRefresh" used in the request body. @@ -24,7 +28,9 @@ def __normalize_task_type(self, task_type): return task_type @api(version="2.6") - def get(self, req_options=None, task_type=TaskItem.Type.ExtractRefresh): + def get( + self, req_options: Optional["RequestOptions"] = None, task_type: str = TaskItem.Type.ExtractRefresh + ) -> Tuple[List[TaskItem], PaginationItem]: if task_type == TaskItem.Type.DataAcceleration: self.parent_srv.assert_at_least_version("3.8", "Data Acceleration Tasks") @@ -38,7 +44,7 @@ def get(self, req_options=None, task_type=TaskItem.Type.ExtractRefresh): return all_tasks, pagination_item @api(version="2.6") - def get_by_id(self, task_id): + def get_by_id(self, task_id: str) -> TaskItem: if not task_id: error = "No Task ID provided" raise ValueError(error) @@ -63,7 +69,7 @@ def create(self, extract_item: TaskItem) -> TaskItem: return server_response.content @api(version="2.6") - def run(self, task_item): + def run(self, task_item: TaskItem) -> bytes: if not task_item.id: error = "Task item missing ID." raise MissingRequiredFieldError(error) @@ -79,7 +85,7 @@ def run(self, task_item): # Delete 1 task by id @api(version="3.6") - def delete(self, task_id, task_type=TaskItem.Type.ExtractRefresh): + def delete(self, task_id: str, task_type: str = TaskItem.Type.ExtractRefresh) -> None: if task_type == TaskItem.Type.DataAcceleration: self.parent_srv.assert_at_least_version("3.8", "Data Acceleration Tasks") From 36a5547617d2f7d53ae4eaf44f7e5116b29a7181 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Sat, 14 Oct 2023 22:40:16 -0500 Subject: [PATCH 076/296] Permit creation of tasks without schedules --- tableauserverclient/server/request_factory.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 7fb9bf9ed..6316527ec 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1032,6 +1032,16 @@ def run_req(self, xml_request, task_item): def create_extract_req(self, xml_request: ET.Element, extract_item: "TaskItem") -> bytes: extract_element = ET.SubElement(xml_request, "extractRefresh") + # Main attributes + extract_element.attrib["type"] = extract_item.task_type + + if extract_item.target is not None: + target_element = ET.SubElement(extract_element, extract_item.target.type) + target_element.attrib["id"] = extract_item.target.id + + if extract_item.schedule_item is None: + return ET.tostring(xml_request) + # Schedule attributes schedule_element = ET.SubElement(xml_request, "schedule") @@ -1043,17 +1053,11 @@ def create_extract_req(self, xml_request: ET.Element, extract_item: "TaskItem") frequency_element.attrib["end"] = str(interval_item.end_time) if hasattr(interval_item, "interval") and interval_item.interval: intervals_element = ET.SubElement(frequency_element, "intervals") - for interval in interval_item._interval_type_pairs(): + for interval in interval_item._interval_type_pairs(): # type: ignore expression, value = interval single_interval_element = ET.SubElement(intervals_element, "interval") single_interval_element.attrib[expression] = value - # Main attributes - extract_element.attrib["type"] = extract_item.task_type - - target_element = ET.SubElement(extract_element, extract_item.target.type) - target_element.attrib["id"] = extract_item.target.id - return ET.tostring(xml_request) From 11656c4955508f44bcdb13a496989e185ab7e5ae Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Sun, 15 Oct 2023 20:09:19 -0500 Subject: [PATCH 077/296] Fix logging format --- tableauserverclient/server/endpoint/tasks_endpoint.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index 383f0984e..a727a515f 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -34,7 +34,7 @@ def get( if task_type == TaskItem.Type.DataAcceleration: self.parent_srv.assert_at_least_version("3.8", "Data Acceleration Tasks") - logger.info("Querying all {} tasks for the site".format(task_type)) + logger.info("Querying all %s tasks for the site", task_type) url = "{0}/{1}".format(self.baseurl, self.__normalize_task_type(task_type)) server_response = self.get_request(url, req_options) @@ -48,7 +48,7 @@ def get_by_id(self, task_id: str) -> TaskItem: if not task_id: error = "No Task ID provided" raise ValueError(error) - logger.info("Querying a single task by id ({})".format(task_id)) + logger.info("Querying a single task by id %s", task_id) url = "{}/{}/{}".format( self.baseurl, self.__normalize_task_type(TaskItem.Type.ExtractRefresh), @@ -62,7 +62,7 @@ def create(self, extract_item: TaskItem) -> TaskItem: if not extract_item: error = "No extract refresh provided" raise ValueError(error) - logger.info("Creating an extract refresh ({})".format(extract_item)) + logger.info("Creating an extract refresh %s", extract_item) url = "{0}/{1}".format(self.baseurl, self.__normalize_task_type(TaskItem.Type.ExtractRefresh)) create_req = RequestFactory.Task.create_extract_req(extract_item) server_response = self.post_request(url, create_req) @@ -94,4 +94,4 @@ def delete(self, task_id: str, task_type: str = TaskItem.Type.ExtractRefresh) -> raise ValueError(error) url = "{0}/{1}/{2}".format(self.baseurl, self.__normalize_task_type(task_type), task_id) self.delete_request(url) - logger.info("Deleted single task (ID: {0})".format(task_id)) + logger.info("Deleted single task (ID: %s)", task_id) From 246b44974a328a6088003ba5f2242392efb76e39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=BCger?= Date: Thu, 19 Oct 2023 15:42:12 +0200 Subject: [PATCH 078/296] issue-1299 set empty async response to None --- tableauserverclient/config.py | 2 +- .../server/endpoint/endpoint.py | 19 +++++++++---------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/tableauserverclient/config.py b/tableauserverclient/config.py index 67a77f479..1a4a7dc37 100644 --- a/tableauserverclient/config.py +++ b/tableauserverclient/config.py @@ -7,7 +7,7 @@ # For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks CHUNK_SIZE_MB = 5 * 10 # 5MB felt too slow, upped it to 50 -DELAY_SLEEP_SECONDS = 10 +DELAY_SLEEP_SECONDS = 0.1 # The maximum size of a file that can be published in a single request is 64MB FILESIZE_LIMIT_MB = 64 diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index c11a3fb27..5d84d8e7f 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -76,7 +76,7 @@ def set_user_agent(parameters): # return explicitly for testing only return parameters - def _blocking_request(self, method, url, parameters={}) -> Optional["Response"]: + def _blocking_request(self, method, url, parameters={}) -> Optional[Union["Response", Exception]]: self.async_response = None response = None logger.debug("[{}] Begin blocking request to {}".format(datetime.timestamp(), url)) @@ -96,32 +96,31 @@ def _blocking_request(self, method, url, parameters={}) -> Optional["Response"]: def send_request_while_show_progress_threaded( self, method, url, parameters={}, request_timeout=0 - ) -> Optional["Response"]: + ) -> Optional[Union["Response", Exception]]: try: request_thread = Thread(target=self._blocking_request, args=(method, url, parameters)) - request_thread.async_response = -1 # type:ignore # this is an invented attribute for thread comms request_thread.start() except Exception as e: logger.debug("Error starting server request on separate thread: {}".format(e)) return None - seconds = 0 + seconds = 0.05 minutes = 0 - sleep(1) - if self.async_response != -1: + sleep(seconds) + if self.async_response is not None: # a quick return for any immediate responses return self.async_response - while self.async_response == -1 and (request_timeout == 0 or seconds < request_timeout): + while (self.async_response is None) and (request_timeout == 0 or seconds < request_timeout): self.log_wait_time_then_sleep(minutes, seconds, url) seconds = seconds + DELAY_SLEEP_SECONDS if seconds >= 60: - seconds = 0 - minutes = minutes + 1 + seconds -= 60 + minutes += 1 return self.async_response def log_wait_time_then_sleep(self, minutes, seconds, url): logger.debug("{} Waiting....".format(datetime.timestamp())) if seconds >= 60: # detailed log message ~every minute - if minutes % 5 == 0: + if minutes % 1 == 0: logger.info( "[{}] Waiting ({} minutes so far) for request to {}".format(datetime.timestamp(), minutes, url) ) From 88d46142cc47fca2655c34a1fe856d391f40b8a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=BCger?= Date: Thu, 19 Oct 2023 15:44:48 +0200 Subject: [PATCH 079/296] issue-1299 remove unused import --- tableauserverclient/server/endpoint/endpoint.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 5d84d8e7f..aa22acfb1 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -2,7 +2,6 @@ from time import sleep from tableauserverclient import datetime_helpers as datetime -import requests from packaging.version import Version from functools import wraps from xml.etree.ElementTree import ParseError From 3ff3131d95c535f1c1e37615d880c2dec0ab433e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=BCger?= Date: Thu, 19 Oct 2023 16:10:49 +0200 Subject: [PATCH 080/296] issue-1299 fix timeout missed when longer than 60s --- .../server/endpoint/endpoint.py | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index aa22acfb1..8e02933ca 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -94,7 +94,7 @@ def _blocking_request(self, method, url, parameters={}) -> Optional[Union["Respo return self.async_response def send_request_while_show_progress_threaded( - self, method, url, parameters={}, request_timeout=0 + self, method, url, parameters={}, request_timeout=None ) -> Optional[Union["Response", Exception]]: try: request_thread = Thread(target=self._blocking_request, args=(method, url, parameters)) @@ -104,28 +104,29 @@ def send_request_while_show_progress_threaded( return None seconds = 0.05 minutes = 0 + last_log_minute = 0 sleep(seconds) if self.async_response is not None: # a quick return for any immediate responses return self.async_response - while (self.async_response is None) and (request_timeout == 0 or seconds < request_timeout): - self.log_wait_time_then_sleep(minutes, seconds, url) + timed_out: bool = (request_timeout is not None and seconds > request_timeout) + while (self.async_response is None) and not timed_out: + sleep(DELAY_SLEEP_SECONDS) seconds = seconds + DELAY_SLEEP_SECONDS - if seconds >= 60: - seconds -= 60 - minutes += 1 + minutes = int(seconds/60) + last_log_minute = self.log_wait_time(minutes, last_log_minute, url) return self.async_response - def log_wait_time_then_sleep(self, minutes, seconds, url): + def log_wait_time(self, minutes, last_log_minute, url) -> int: logger.debug("{} Waiting....".format(datetime.timestamp())) - if seconds >= 60: # detailed log message ~every minute - if minutes % 1 == 0: - logger.info( - "[{}] Waiting ({} minutes so far) for request to {}".format(datetime.timestamp(), minutes, url) - ) - else: - logger.debug("[{}] Waiting for request to {}".format(datetime.timestamp(), url)) - sleep(DELAY_SLEEP_SECONDS) + if minutes > last_log_minute: # detailed log message ~every minute + logger.info( + "[{}] Waiting ({} minutes so far) for request to {}".format(datetime.timestamp(), minutes, url) + ) + last_log_minute = minutes + else: + logger.debug("[{}] Waiting for request to {}".format(datetime.timestamp(), url)) + return last_log_minute def _make_request( self, From f7d60f94ec7ee3171e649169c1c4a9f4b4cb729f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=BCger?= Date: Fri, 20 Oct 2023 15:48:00 +0200 Subject: [PATCH 081/296] issue-1299 paint it black --- tableauserverclient/server/endpoint/endpoint.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 8e02933ca..5dbf3c9b8 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -109,21 +109,19 @@ def send_request_while_show_progress_threaded( if self.async_response is not None: # a quick return for any immediate responses return self.async_response - timed_out: bool = (request_timeout is not None and seconds > request_timeout) + timed_out: bool = request_timeout is not None and seconds > request_timeout while (self.async_response is None) and not timed_out: sleep(DELAY_SLEEP_SECONDS) seconds = seconds + DELAY_SLEEP_SECONDS - minutes = int(seconds/60) + minutes = int(seconds / 60) last_log_minute = self.log_wait_time(minutes, last_log_minute, url) return self.async_response def log_wait_time(self, minutes, last_log_minute, url) -> int: logger.debug("{} Waiting....".format(datetime.timestamp())) if minutes > last_log_minute: # detailed log message ~every minute - logger.info( - "[{}] Waiting ({} minutes so far) for request to {}".format(datetime.timestamp(), minutes, url) - ) - last_log_minute = minutes + logger.info("[{}] Waiting ({} minutes so far) for request to {}".format(datetime.timestamp(), minutes, url)) + last_log_minute = minutes else: logger.debug("[{}] Waiting for request to {}".format(datetime.timestamp(), url)) return last_log_minute From 538324e8bab057394305f61e6e57dd2f474de0d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=BCger?= Date: Wed, 25 Oct 2023 15:49:03 +0200 Subject: [PATCH 082/296] issue-1299 raise exception when returned from blocking request --- tableauserverclient/server/endpoint/endpoint.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 5dbf3c9b8..c97091d98 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -148,7 +148,7 @@ def _make_request( # a request can, for stuff like publishing, spin for ages waiting for a response. # we need some user-facing activity so they know it's not dead. request_timeout = self.parent_srv.http_options.get("timeout") or 0 - server_response: Optional["Response"] = self.send_request_while_show_progress_threaded( + server_response: Optional[Union["Response",Exception]] = self.send_request_while_show_progress_threaded( method, url, parameters, request_timeout ) logger.debug("[{}] Async request returned: received {}".format(datetime.timestamp(), server_response)) @@ -160,6 +160,8 @@ def _make_request( if server_response is None: logger.debug("[{}] Request failed".format(datetime.timestamp())) raise RuntimeError + if isinstance(server_response, Exception): + raise server_response self._check_status(server_response, url) loggable_response = self.log_response_safely(server_response) From 5653a3eabf4beaf0512521745afbdb6f5314cf00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=BCger?= Date: Wed, 15 Nov 2023 18:49:56 +0100 Subject: [PATCH 083/296] issue-1299 black line length 120 --- tableauserverclient/server/endpoint/endpoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index c97091d98..77a771288 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -148,7 +148,7 @@ def _make_request( # a request can, for stuff like publishing, spin for ages waiting for a response. # we need some user-facing activity so they know it's not dead. request_timeout = self.parent_srv.http_options.get("timeout") or 0 - server_response: Optional[Union["Response",Exception]] = self.send_request_while_show_progress_threaded( + server_response: Optional[Union["Response", Exception]] = self.send_request_while_show_progress_threaded( method, url, parameters, request_timeout ) logger.debug("[{}] Async request returned: received {}".format(datetime.timestamp(), server_response)) From 1f9088f7637b46214fd98c2db43249d38c7d66c4 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 1 Dec 2023 19:43:20 -0600 Subject: [PATCH 084/296] fix: correct type hint on download_revision revision_number --- tableauserverclient/server/endpoint/workbooks_endpoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 3c8efbe3b..dbcc1ec53 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -455,7 +455,7 @@ def _get_workbook_revisions( def download_revision( self, workbook_id: str, - revision_number: str, + revision_number: Optional[str], filepath: Optional[PathOrFileW] = None, include_extract: bool = True, no_extract: Optional[bool] = None, From f42948a1bee9e7f122764ecc2c9cf1c9d6877ea1 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Sat, 9 Dec 2023 22:01:37 -0600 Subject: [PATCH 085/296] fix: handle filename* in download response --- .gitignore | 2 ++ tableauserverclient/helpers/headers.py | 19 +++++++++++++++++++ .../server/endpoint/datasources_endpoint.py | 3 +++ .../server/endpoint/flows_endpoint.py | 3 +++ .../server/endpoint/workbooks_endpoint.py | 3 +++ test/test_datasource.py | 14 ++++++++++++++ test/test_flow.py | 15 +++++++++++++++ test/test_workbook.py | 14 ++++++++++++++ 8 files changed, 73 insertions(+) create mode 100644 tableauserverclient/helpers/headers.py diff --git a/.gitignore b/.gitignore index f0226c065..e9bd2b49f 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ var/ *.egg-info/ .installed.cfg *.egg +pip-wheel-metadata/ # PyInstaller # Usually these files are written by a python script from a template @@ -89,6 +90,7 @@ env.py # virtualenv venv/ ENV/ +.venv/ # Spyder project settings .spyderproject diff --git a/tableauserverclient/helpers/headers.py b/tableauserverclient/helpers/headers.py new file mode 100644 index 000000000..18b4eacd6 --- /dev/null +++ b/tableauserverclient/helpers/headers.py @@ -0,0 +1,19 @@ +from copy import deepcopy +from typing import Any, Generic, Mapping, Optional, TypeVar, Union +from urllib.parse import unquote_plus + +T = TypeVar("T", ) + +def fix_filename(params: Mapping[str, T]) -> Mapping[str, T]: + if "filename*" not in params: + return params + + params = deepcopy(params) + filename = params["filename*"] + prefix = "UTF-8''" + if filename.startswith(prefix): + filename = filename[len(prefix):] + + params["filename"] = unquote_plus(filename) + del params["filename*"] + return params \ No newline at end of file diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index c60f8f919..66ad9f710 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -8,6 +8,8 @@ from pathlib import Path from typing import List, Mapping, Optional, Sequence, Tuple, TYPE_CHECKING, Union +from tableauserverclient.helpers.headers import fix_filename + if TYPE_CHECKING: from tableauserverclient.server import Server from tableauserverclient.models import PermissionsRule @@ -441,6 +443,7 @@ def download_revision( filepath.write(chunk) return_path = filepath else: + params = fix_filename(params) filename = to_filename(os.path.basename(params["filename"])) download_path = make_download_path(filepath, filename) with open(download_path, "wb") as f: diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index ba8a152d7..21c16b1cc 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -7,6 +7,8 @@ from pathlib import Path from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union +from tableauserverclient.helpers.headers import fix_filename + from .dqw_endpoint import _DataQualityWarningEndpoint from .endpoint import QuerysetEndpoint, api from .exceptions import InternalServerError, MissingRequiredFieldError @@ -124,6 +126,7 @@ def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> Path filepath.write(chunk) return_path = filepath else: + params = fix_filename(params) filename = to_filename(os.path.basename(params["filename"])) download_path = make_download_path(filepath, filename) with open(download_path, "wb") as f: diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index dbcc1ec53..506fe02c2 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -6,6 +6,8 @@ from contextlib import closing from pathlib import Path +from tableauserverclient.helpers.headers import fix_filename + from .endpoint import QuerysetEndpoint, api, parameter_added_in from .exceptions import InternalServerError, MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint @@ -487,6 +489,7 @@ def download_revision( filepath.write(chunk) return_path = filepath else: + params = fix_filename(params) filename = to_filename(os.path.basename(params["filename"])) download_path = make_download_path(filepath, filename) with open(download_path, "wb") as f: diff --git a/test/test_datasource.py b/test/test_datasource.py index e299e5291..c79bf45fd 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -696,3 +696,17 @@ def test_download_revision(self) -> None: ) file_path = self.server.datasources.download_revision("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", "3", td) self.assertTrue(os.path.exists(file_path)) + + def test_bad_download_response(self) -> None: + with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td: + m.get( + self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content", + headers={ + "Content-Disposition": '''name="tableau_datasource"; filename*=UTF-8''"Sample datasource.tds"''' + } + ) + file_path = self.server.datasources.download( + "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", + td + ) + self.assertTrue(os.path.exists(file_path)) \ No newline at end of file diff --git a/test/test_flow.py b/test/test_flow.py index d10641809..d7fa2dbc3 100644 --- a/test/test_flow.py +++ b/test/test_flow.py @@ -1,5 +1,6 @@ import os import requests_mock +import tempfile import unittest from io import BytesIO @@ -203,3 +204,17 @@ def test_refresh(self): self.assertEqual(refresh_job.flow_run.id, "e0c3067f-2333-4eee-8028-e0a56ca496f6") self.assertEqual(refresh_job.flow_run.flow_id, "92967d2d-c7e2-46d0-8847-4802df58f484") self.assertEqual(format_datetime(refresh_job.flow_run.started_at), "2018-05-22T13:00:29Z") + + def test_bad_download_response(self) -> None: + with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td: + m.get( + self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content", + headers={ + "Content-Disposition": '''name="tableau_flow"; filename*=UTF-8''"Sample flow.tfl"''' + } + ) + file_path = self.server.flows.download( + "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", + td + ) + self.assertTrue(os.path.exists(file_path)) diff --git a/test/test_workbook.py b/test/test_workbook.py index 5114ce1b8..9804b2c02 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -932,3 +932,17 @@ def test_download_revision(self) -> None: ) file_path = self.server.workbooks.download_revision("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", "3", td) self.assertTrue(os.path.exists(file_path)) + + def test_bad_download_response(self) -> None: + with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td: + m.get( + self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content", + headers={ + "Content-Disposition": '''name="tableau_workbook"; filename*=UTF-8''"Sample workbook.twb"''' + } + ) + file_path = self.server.workbooks.download( + "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", + td + ) + self.assertTrue(os.path.exists(file_path)) From 76559d4c0456034a610818fd3bace51067e7ba07 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Sat, 9 Dec 2023 22:06:05 -0600 Subject: [PATCH 086/296] style: black formatting --- tableauserverclient/helpers/headers.py | 11 +++++++---- test/test_datasource.py | 9 +++------ test/test_flow.py | 9 ++------- test/test_workbook.py | 9 ++------- 4 files changed, 14 insertions(+), 24 deletions(-) diff --git a/tableauserverclient/helpers/headers.py b/tableauserverclient/helpers/headers.py index 18b4eacd6..57be21b23 100644 --- a/tableauserverclient/helpers/headers.py +++ b/tableauserverclient/helpers/headers.py @@ -2,18 +2,21 @@ from typing import Any, Generic, Mapping, Optional, TypeVar, Union from urllib.parse import unquote_plus -T = TypeVar("T", ) +T = TypeVar( + "T", +) + def fix_filename(params: Mapping[str, T]) -> Mapping[str, T]: if "filename*" not in params: return params - + params = deepcopy(params) filename = params["filename*"] prefix = "UTF-8''" if filename.startswith(prefix): - filename = filename[len(prefix):] + filename = filename[len(prefix) :] params["filename"] = unquote_plus(filename) del params["filename*"] - return params \ No newline at end of file + return params diff --git a/test/test_datasource.py b/test/test_datasource.py index c79bf45fd..f258fdc52 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -703,10 +703,7 @@ def test_bad_download_response(self) -> None: self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content", headers={ "Content-Disposition": '''name="tableau_datasource"; filename*=UTF-8''"Sample datasource.tds"''' - } - ) - file_path = self.server.datasources.download( - "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", - td + }, ) - self.assertTrue(os.path.exists(file_path)) \ No newline at end of file + file_path = self.server.datasources.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", td) + self.assertTrue(os.path.exists(file_path)) diff --git a/test/test_flow.py b/test/test_flow.py index d7fa2dbc3..a90b18171 100644 --- a/test/test_flow.py +++ b/test/test_flow.py @@ -209,12 +209,7 @@ def test_bad_download_response(self) -> None: with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td: m.get( self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content", - headers={ - "Content-Disposition": '''name="tableau_flow"; filename*=UTF-8''"Sample flow.tfl"''' - } - ) - file_path = self.server.flows.download( - "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", - td + headers={"Content-Disposition": '''name="tableau_flow"; filename*=UTF-8''"Sample flow.tfl"'''}, ) + file_path = self.server.flows.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", td) self.assertTrue(os.path.exists(file_path)) diff --git a/test/test_workbook.py b/test/test_workbook.py index 9804b2c02..212d55a37 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -937,12 +937,7 @@ def test_bad_download_response(self) -> None: with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td: m.get( self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content", - headers={ - "Content-Disposition": '''name="tableau_workbook"; filename*=UTF-8''"Sample workbook.twb"''' - } - ) - file_path = self.server.workbooks.download( - "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", - td + headers={"Content-Disposition": '''name="tableau_workbook"; filename*=UTF-8''"Sample workbook.twb"'''}, ) + file_path = self.server.workbooks.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", td) self.assertTrue(os.path.exists(file_path)) From 19a9f51ab7ab65a1819bfabf28f885d2fe0df7e2 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Sat, 9 Dec 2023 22:12:15 -0600 Subject: [PATCH 087/296] fix: strip typing from fix_filename --- tableauserverclient/helpers/headers.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tableauserverclient/helpers/headers.py b/tableauserverclient/helpers/headers.py index 57be21b23..2ed4a814d 100644 --- a/tableauserverclient/helpers/headers.py +++ b/tableauserverclient/helpers/headers.py @@ -1,13 +1,8 @@ from copy import deepcopy -from typing import Any, Generic, Mapping, Optional, TypeVar, Union from urllib.parse import unquote_plus -T = TypeVar( - "T", -) - -def fix_filename(params: Mapping[str, T]) -> Mapping[str, T]: +def fix_filename(params): if "filename*" not in params: return params From f17a75d14cc0d516a01ec2676326d42f29a1866c Mon Sep 17 00:00:00 2001 From: a-torres-2 <142839181+a-torres-2@users.noreply.github.com> Date: Wed, 13 Dec 2023 10:49:52 -0800 Subject: [PATCH 088/296] add support for multiple intervals for hourly, daily, and monthly schedules --- tableauserverclient/models/interval_item.py | 124 +++++++++++++++----- tableauserverclient/models/schedule_item.py | 36 ++++-- test/assets/schedule_get_daily_id.xml | 11 ++ test/assets/schedule_get_hourly_id.xml | 11 ++ test/assets/schedule_get_monthly_id.xml | 11 ++ test/test_schedule.py | 52 +++++++- 6 files changed, 205 insertions(+), 40 deletions(-) create mode 100644 test/assets/schedule_get_daily_id.xml create mode 100644 test/assets/schedule_get_hourly_id.xml create mode 100644 test/assets/schedule_get_monthly_id.xml diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index 25b6d09d7..44c24a6f6 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -29,7 +29,12 @@ class HourlyInterval(object): def __init__(self, start_time, end_time, interval_value): self.start_time = start_time self.end_time = end_time - self.interval = interval_value + + # interval should be a tuple, if it is not, assign as a tuple with single value + if isinstance(interval_value, tuple): + self.interval = interval_value + else: + self.interval = (interval_value,) @property def _frequency(self): @@ -60,25 +65,44 @@ def interval(self): return self._interval @interval.setter - def interval(self, interval): + def interval(self, intervals): VALID_INTERVALS = {0.25, 0.5, 1, 2, 4, 6, 8, 12} - if float(interval) not in VALID_INTERVALS: - error = "Invalid interval {} not in {}".format(interval, str(VALID_INTERVALS)) - raise ValueError(error) + for interval in intervals: + # if an hourly interval is a string, then it is a weekDay interval + if isinstance(interval, str) and not interval.isnumeric() and not hasattr(IntervalItem.Day, interval): + error = "Invalid weekDay interval {}".format(interval) + raise ValueError(error) + + # if an hourly interval is a number, it is an hours or minutes interval + if isinstance(interval, (int, float)) and float(interval) not in VALID_INTERVALS: + error = "Invalid interval {} not in {}".format(interval, str(VALID_INTERVALS)) + raise ValueError(error) - self._interval = interval + self._interval = intervals def _interval_type_pairs(self): - # We use fractional hours for the two minute-based intervals. - # Need to convert to minutes from hours here - if self.interval in {0.25, 0.5}: - calculated_interval = int(self.interval * 60) - interval_type = IntervalItem.Occurrence.Minutes - else: - calculated_interval = self.interval - interval_type = IntervalItem.Occurrence.Hours + interval_type_pairs = [] + for interval in self.interval: + # We use fractional hours for the two minute-based intervals. + # Need to convert to minutes from hours here + if interval in {0.25, 0.5}: + calculated_interval = int(interval * 60) + interval_type = IntervalItem.Occurrence.Minutes + + interval_type_pairs.append((interval_type, str(calculated_interval))) + else: + # if the interval is a non-numeric string, it will always be a weekDay + if isinstance(interval, str) and not interval.isnumeric(): + interval_type = IntervalItem.Occurrence.WeekDay + + interval_type_pairs.append((interval_type, str(interval))) + # otherwise the interval is hours + else: + interval_type = IntervalItem.Occurrence.Hours - return [(interval_type, str(calculated_interval))] + interval_type_pairs.append((interval_type, str(interval))) + + return interval_type_pairs class DailyInterval(object): @@ -105,8 +129,45 @@ def interval(self): return self._interval @interval.setter - def interval(self, interval): - self._interval = interval + def interval(self, intervals): + VALID_INTERVALS = {0.25, 0.5, 1, 2, 4, 6, 8, 12} + + for interval in intervals: + # if an hourly interval is a string, then it is a weekDay interval + if isinstance(interval, str) and not interval.isnumeric() and not hasattr(IntervalItem.Day, interval): + error = "Invalid weekDay interval {}".format(interval) + raise ValueError(error) + + # if an hourly interval is a number, it is an hours or minutes interval + if isinstance(interval, (int, float)) and float(interval) not in VALID_INTERVALS: + error = "Invalid interval {} not in {}".format(interval, str(VALID_INTERVALS)) + raise ValueError(error) + + self._interval = intervals + + def _interval_type_pairs(self): + interval_type_pairs = [] + for interval in self.interval: + # We use fractional hours for the two minute-based intervals. + # Need to convert to minutes from hours here + if interval in {0.25, 0.5}: + calculated_interval = int(interval * 60) + interval_type = IntervalItem.Occurrence.Minutes + + interval_type_pairs.append((interval_type, str(calculated_interval))) + else: + # if the interval is a non-numeric string, it will always be a weekDay + if isinstance(interval, str) and not interval.isnumeric(): + interval_type = IntervalItem.Occurrence.WeekDay + + interval_type_pairs.append((interval_type, str(interval))) + # otherwise the interval is hours + else: + interval_type = IntervalItem.Occurrence.Hours + + interval_type_pairs.append((interval_type, str(interval))) + + return interval_type_pairs class WeeklyInterval(object): @@ -146,7 +207,12 @@ def _interval_type_pairs(self): class MonthlyInterval(object): def __init__(self, start_time, interval_value): self.start_time = start_time - self.interval = str(interval_value) + + # interval should be a tuple, if it is not, assign as a tuple with single value + if isinstance(interval_value, tuple): + self.interval = interval_value + else: + self.interval = (interval_value,) @property def _frequency(self): @@ -167,24 +233,24 @@ def interval(self): return self._interval @interval.setter - def interval(self, interval_value): - error = "Invalid interval value for a monthly frequency: {}.".format(interval_value) - + def interval(self, interval_values): # This is weird because the value could be a str or an int # The only valid str is 'LastDay' so we check that first. If that's not it # try to convert it to an int, if that fails because it's an incorrect string # like 'badstring' we catch and re-raise. Otherwise we convert to int and check # that it's in range 1-31 + for interval_value in interval_values: + error = "Invalid interval value for a monthly frequency: {}.".format(interval_value) - if interval_value != "LastDay": - try: - if not (1 <= int(interval_value) <= 31): - raise ValueError(error) - except ValueError: - if interval_value != "LastDay": - raise ValueError(error) + if interval_value != "LastDay": + try: + if not (1 <= int(interval_value) <= 31): + raise ValueError(error) + except ValueError: + if interval_value != "LastDay": + raise ValueError(error) - self._interval = str(interval_value) + self._interval = interval_values def _interval_type_pairs(self): return [(IntervalItem.Occurrence.MonthDay, self.interval)] diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index edfd0fe70..23796ff46 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -251,25 +251,43 @@ def _parse_interval_item(parsed_response, frequency, ns): interval.extend(interval_elem.attrib.items()) if frequency == IntervalItem.Frequency.Daily: - return DailyInterval(start_time) + converted_intervals = [] + + for i in interval: + # We use fractional hours for the two minute-based intervals. + # Need to convert to hours from minutes here + if i[0] == IntervalItem.Occurrence.Minutes: + converted_intervals.append(float(i[1]) / 60) + elif i[0] == IntervalItem.Occurrence.Hours: + converted_intervals.append(float(i[1])) + else: + converted_intervals.append(i[1]) + + return DailyInterval(start_time, *converted_intervals) if frequency == IntervalItem.Frequency.Hourly: - interval_occurrence, interval_value = interval.pop() + converted_intervals = [] - # We use fractional hours for the two minute-based intervals. - # Need to convert to hours from minutes here - if interval_occurrence == IntervalItem.Occurrence.Minutes: - interval_value = float(interval_value) / 60 + for i in interval: + # We use fractional hours for the two minute-based intervals. + # Need to convert to hours from minutes here + if i[0] == IntervalItem.Occurrence.Minutes: + converted_intervals.append(float(i[1]) / 60) + elif i[0] == IntervalItem.Occurrence.Hours: + converted_intervals.append(i[1]) + else: + converted_intervals.append(i[1]) - return HourlyInterval(start_time, end_time, interval_value) + return HourlyInterval(start_time, end_time, tuple(converted_intervals)) if frequency == IntervalItem.Frequency.Weekly: interval_values = [i[1] for i in interval] return WeeklyInterval(start_time, *interval_values) if frequency == IntervalItem.Frequency.Monthly: - interval_occurrence, interval_value = interval.pop() - return MonthlyInterval(start_time, interval_value) + interval_values = [i[1] for i in interval] + + return MonthlyInterval(start_time, tuple(interval_values)) @staticmethod def _parse_element(schedule_xml, ns): diff --git a/test/assets/schedule_get_daily_id.xml b/test/assets/schedule_get_daily_id.xml new file mode 100644 index 000000000..99467a391 --- /dev/null +++ b/test/assets/schedule_get_daily_id.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/schedule_get_hourly_id.xml b/test/assets/schedule_get_hourly_id.xml new file mode 100644 index 000000000..27c374ccf --- /dev/null +++ b/test/assets/schedule_get_hourly_id.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/schedule_get_monthly_id.xml b/test/assets/schedule_get_monthly_id.xml new file mode 100644 index 000000000..3fc32cc57 --- /dev/null +++ b/test/assets/schedule_get_monthly_id.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/test/test_schedule.py b/test/test_schedule.py index 807467918..76c8720b9 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -11,6 +11,9 @@ GET_XML = os.path.join(TEST_ASSET_DIR, "schedule_get.xml") GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_by_id.xml") +GET_HOURLY_ID_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_hourly_id.xml") +GET_DAILY_ID_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_daily_id.xml") +GET_MONTHLY_ID_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_monthly_id.xml") GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_empty.xml") CREATE_HOURLY_XML = os.path.join(TEST_ASSET_DIR, "schedule_create_hourly.xml") CREATE_DAILY_XML = os.path.join(TEST_ASSET_DIR, "schedule_create_daily.xml") @@ -100,6 +103,51 @@ def test_get_by_id(self) -> None: self.assertEqual("Weekday early mornings", schedule.name) self.assertEqual("Active", schedule.state) + def test_get_hourly_by_id(self) -> None: + self.server.version = "3.8" + with open(GET_HOURLY_ID_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" + baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + m.get(baseurl, text=response_xml) + schedule = self.server.schedules.get_by_id(schedule_id) + self.assertIsNotNone(schedule) + self.assertEqual(schedule_id, schedule.id) + self.assertEqual("Hourly schedule", schedule.name) + self.assertEqual("Active", schedule.state) + self.assertEqual(("Monday", 0.5), schedule.interval_item.interval) + + def test_get_daily_by_id(self) -> None: + self.server.version = "3.8" + with open(GET_DAILY_ID_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" + baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + m.get(baseurl, text=response_xml) + schedule = self.server.schedules.get_by_id(schedule_id) + self.assertIsNotNone(schedule) + self.assertEqual(schedule_id, schedule.id) + self.assertEqual("Daily schedule", schedule.name) + self.assertEqual("Active", schedule.state) + self.assertEqual(("Monday", 2.0), schedule.interval_item.interval) + + def test_get_monthly_by_id(self) -> None: + self.server.version = "3.8" + with open(GET_MONTHLY_ID_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" + baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + m.get(baseurl, text=response_xml) + schedule = self.server.schedules.get_by_id(schedule_id) + self.assertIsNotNone(schedule) + self.assertEqual(schedule_id, schedule.id) + self.assertEqual("Monthly multiple days", schedule.name) + self.assertEqual("Active", schedule.state) + self.assertEqual(("1", "2"), schedule.interval_item.interval) + def test_delete(self) -> None: with requests_mock.mock() as m: m.delete(self.baseurl + "/c9cff7f9-309c-4361-99ff-d4ba8c9f5467", status_code=204) @@ -131,7 +179,7 @@ def test_create_hourly(self) -> None: self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Parallel, new_schedule.execution_order) self.assertEqual(time(2, 30), new_schedule.interval_item.start_time) self.assertEqual(time(23), new_schedule.interval_item.end_time) # type: ignore[union-attr] - self.assertEqual("8", new_schedule.interval_item.interval) # type: ignore[union-attr] + self.assertEqual(("8",), new_schedule.interval_item.interval) # type: ignore[union-attr] def test_create_daily(self) -> None: with open(CREATE_DAILY_XML, "rb") as f: @@ -216,7 +264,7 @@ def test_create_monthly(self) -> None: self.assertEqual("2016-10-12T14:00:00Z", format_datetime(new_schedule.next_run_at)) self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Serial, new_schedule.execution_order) self.assertEqual(time(7), new_schedule.interval_item.start_time) - self.assertEqual("12", new_schedule.interval_item.interval) # type: ignore[union-attr] + self.assertEqual(("12",), new_schedule.interval_item.interval) # type: ignore[union-attr] def test_update(self) -> None: with open(UPDATE_XML, "rb") as f: From bbb45d427405b4f024708892c5819b3247bc00c3 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Tue, 16 Jan 2024 12:35:17 -0800 Subject: [PATCH 089/296] Update all action versions --- .github/workflows/code-coverage.yml | 4 ++-- .github/workflows/meta-checks.yml | 4 ++-- .github/workflows/publish-pypi.yml | 4 ++-- .github/workflows/pypi-smoke-tests.yml | 2 +- .github/workflows/run-tests.yml | 4 ++-- .github/workflows/slack.yml | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index e153c1fc7..70bc845e9 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@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/meta-checks.yml b/.github/workflows/meta-checks.yml index 7d6cd068a..41a944e63 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@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 330bfe7d3..cae0f409c 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -14,10 +14,10 @@ jobs: name: Build dist files for PyPi runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: 3.9 - name: Build dist files diff --git a/.github/workflows/pypi-smoke-tests.yml b/.github/workflows/pypi-smoke-tests.yml index eb6406573..45ea94400 100644 --- a/.github/workflows/pypi-smoke-tests.yml +++ b/.github/workflows/pypi-smoke-tests.yml @@ -23,7 +23,7 @@ jobs: steps: - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: pip install diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 3df497806..6b1629bfd 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -13,10 +13,10 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/slack.yml b/.github/workflows/slack.yml index b11f4009a..2ecb0be7f 100644 --- a/.github/workflows/slack.yml +++ b/.github/workflows/slack.yml @@ -10,7 +10,7 @@ jobs: steps: - name: Send message to Slack API continue-on-error: true - uses: archive/github-actions-slack@v2.2.2 + uses: archive/github-actions-slack@v2.8.0 id: notify with: slack-bot-user-oauth-access-token: ${{ secrets.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN }} From 21503f4fe160721c1e3c7bfded7d9110bda8360a Mon Sep 17 00:00:00 2001 From: Jac Date: Fri, 19 Jan 2024 01:09:13 -0800 Subject: [PATCH 090/296] remove threading code --- .../server/endpoint/endpoint.py | 43 ++----------------- test/test_endpoint.py | 13 +++--- 2 files changed, 11 insertions(+), 45 deletions(-) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 77a771288..2b7f57069 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,5 +1,3 @@ -from threading import Thread -from time import sleep from tableauserverclient import datetime_helpers as datetime from packaging.version import Version @@ -76,55 +74,20 @@ def set_user_agent(parameters): return parameters def _blocking_request(self, method, url, parameters={}) -> Optional[Union["Response", Exception]]: - self.async_response = None response = None logger.debug("[{}] Begin blocking request to {}".format(datetime.timestamp(), url)) try: response = method(url, **parameters) - self.async_response = response logger.debug("[{}] Call finished".format(datetime.timestamp())) except Exception as e: logger.debug("Error making request to server: {}".format(e)) - self.async_response = e - finally: - if response and not self.async_response: - logger.debug("Request response not saved") - return None - logger.debug("[{}] Request complete".format(datetime.timestamp())) - return self.async_response + raise e + return response def send_request_while_show_progress_threaded( self, method, url, parameters={}, request_timeout=None ) -> Optional[Union["Response", Exception]]: - try: - request_thread = Thread(target=self._blocking_request, args=(method, url, parameters)) - request_thread.start() - except Exception as e: - logger.debug("Error starting server request on separate thread: {}".format(e)) - return None - seconds = 0.05 - minutes = 0 - last_log_minute = 0 - sleep(seconds) - if self.async_response is not None: - # a quick return for any immediate responses - return self.async_response - timed_out: bool = request_timeout is not None and seconds > request_timeout - while (self.async_response is None) and not timed_out: - sleep(DELAY_SLEEP_SECONDS) - seconds = seconds + DELAY_SLEEP_SECONDS - minutes = int(seconds / 60) - last_log_minute = self.log_wait_time(minutes, last_log_minute, url) - return self.async_response - - def log_wait_time(self, minutes, last_log_minute, url) -> int: - logger.debug("{} Waiting....".format(datetime.timestamp())) - if minutes > last_log_minute: # detailed log message ~every minute - logger.info("[{}] Waiting ({} minutes so far) for request to {}".format(datetime.timestamp(), minutes, url)) - last_log_minute = minutes - else: - logger.debug("[{}] Waiting for request to {}".format(datetime.timestamp(), url)) - return last_log_minute + return self._blocking_request(method, url, parameters) def _make_request( self, diff --git a/test/test_endpoint.py b/test/test_endpoint.py index 3d2d1c995..8635af978 100644 --- a/test/test_endpoint.py +++ b/test/test_endpoint.py @@ -1,4 +1,6 @@ from pathlib import Path +import pytest +import requests import unittest import tableauserverclient as TSC @@ -35,11 +37,12 @@ def test_user_friendly_request_returns(self) -> None: ) self.assertIsNotNone(response) - def test_blocking_request_returns(self) -> None: - url = "http://test/" - endpoint = TSC.server.Endpoint(self.server) - response = endpoint._blocking_request(endpoint.parent_srv.session.get, url=url) - self.assertIsNotNone(response) + def test_blocking_request_raises_request_error(self) -> None: + with pytest.raises(requests.exceptions.ConnectionError): + url = "http://test/" + endpoint = TSC.server.Endpoint(self.server) + response = endpoint._blocking_request(endpoint.parent_srv.session.get, url=url) + self.assertIsNotNone(response) def test_get_request_stream(self) -> None: url = "http://test/" From 65f84768dc525639dee7fd9496e4a3d2710ea25f Mon Sep 17 00:00:00 2001 From: Jac Date: Fri, 19 Jan 2024 01:11:39 -0800 Subject: [PATCH 091/296] fix basic sample 1. remove metrics since feature is disabled 2. update VALID_INTERVALS to include all values returned from the server --- samples/getting_started/3_hello_universe.py | 11 +++-------- tableauserverclient/models/interval_item.py | 2 +- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/samples/getting_started/3_hello_universe.py b/samples/getting_started/3_hello_universe.py index 3ed39fd17..077785317 100644 --- a/samples/getting_started/3_hello_universe.py +++ b/samples/getting_started/3_hello_universe.py @@ -62,11 +62,6 @@ def main(): 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)) @@ -82,7 +77,7 @@ def main(): print("{} webhooks".format(pagination.total_available)) print(webhooks[0]) - users, pagination = server.metrics.get() + users, pagination = server.users.get() if users: print("{} users".format(pagination.total_available)) print(users[0]) @@ -92,5 +87,5 @@ def main(): print("{} groups".format(pagination.total_available)) print(groups[0]) - if __name__ == "__main__": - main() +if __name__ == "__main__": + main() diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index f2f159625..537e6c14f 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -69,7 +69,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 if isinstance(interval, str) and not interval.isnumeric() and not hasattr(IntervalItem.Day, interval): From e9a41386dec058f7f36f8cdfe673aba0940c9b86 Mon Sep 17 00:00:00 2001 From: Jac Date: Fri, 19 Jan 2024 01:11:51 -0800 Subject: [PATCH 092/296] format --- .gitignore | 1 + contributing.md | 3 +-- samples/getting_started/3_hello_universe.py | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index e9bd2b49f..92778cd81 100644 --- a/.gitignore +++ b/.gitignore @@ -155,3 +155,4 @@ $RECYCLE.BIN/ docs/_site/ docs/.jekyll-metadata docs/Gemfile.lock +samples/credentials diff --git a/contributing.md b/contributing.md index 41c339cb6..6404611a9 100644 --- a/contributing.md +++ b/contributing.md @@ -10,8 +10,7 @@ Contribution can include, but are not limited to, any of the following: * Fix an Issue/Bug * Add/Fix documentation -Contributions must follow the guidelines outlined on the [Tableau Organization](http://tableau.github.io/) page, though filing an issue or requesting -a feature do not require the CLA. +Contributions must follow the guidelines outlined on the [Tableau Organization](http://tableau.github.io/) page, though filing an issue or requesting a feature do not require the CLA. ## Issues and Feature Requests diff --git a/samples/getting_started/3_hello_universe.py b/samples/getting_started/3_hello_universe.py index 077785317..21de97831 100644 --- a/samples/getting_started/3_hello_universe.py +++ b/samples/getting_started/3_hello_universe.py @@ -87,5 +87,6 @@ def main(): print("{} groups".format(pagination.total_available)) print(groups[0]) + if __name__ == "__main__": main() From 1d3a642fea206108d540ea2fa9a87510af26f363 Mon Sep 17 00:00:00 2001 From: markm Date: Fri, 19 Jan 2024 12:27:31 -0600 Subject: [PATCH 093/296] 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 094/296] 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 095/296] 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 096/296] 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 097/296] 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 098/296] 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 099/296] 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 100/296] 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 101/296] 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 102/296] 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 103/296] 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 104/296] 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 105/296] 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 106/296] 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 107/296] 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 108/296] 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 109/296] 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 110/296] 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 111/296] 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 112/296] 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 113/296] 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 114/296] 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 115/296] 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 116/296] 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 117/296] 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 118/296] 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 119/296] 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 120/296] 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 121/296] 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 122/296] 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 123/296] 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 124/296] 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 125/296] 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 126/296] 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 127/296] 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 4018a0ffc01bfa7c5b0024c6f112ced158d420b9 Mon Sep 17 00:00:00 2001 From: Jac Date: Mon, 3 Jun 2024 12:52:39 -0700 Subject: [PATCH 128/296] v0.31 (#1378) * Changes to alter cgi dependency to email.Messages * Changes to alter cgi dependency to email.Messages * Changes to alter cgi dependency to email.Messages * Changes to alter cgi dependency to email.Messages * feat: allow viz height and width parameters * fix: use python3.8 syntax * fix: python3.8 syntax * docs: comment PDF viz dimensions XOR * 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 * Add failing test retrieving a task with 24 hour (aka daily) interval * Add 24 (hours) as a valid interval which can be returned from the server * Add Python 3.12 to test matrix * Tweak test action to stop double-running everything * feat: add description support on wb publish * 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 * 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 * Add Cloud Flow Task endpoint * cleanup * black format * add xml * edit test initialization * fix task initialization * third times the charm * cleanup * fix formatting * feat: pass parameters in request options * chore: pin typing_extensions version --------- Co-authored-by: markm Co-authored-by: Mark Moreno <45011486+markm-io@users.noreply.github.com> Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Co-authored-by: Brian Cantoni Co-authored-by: Brian Cantoni Co-authored-by: ltiffanydev <148500608+ltiffanydev@users.noreply.github.com> Co-authored-by: liu.r --- .github/workflows/run-tests.yml | 9 +- pyproject.toml | 1 + 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 +++++++++++++++++ tableauserverclient/models/interval_item.py | 2 +- .../models/property_decorators.py | 17 +- tableauserverclient/models/schedule_item.py | 1 + tableauserverclient/models/task_item.py | 1 + tableauserverclient/models/view_item.py | 34 +++ tableauserverclient/models/workbook_item.py | 38 ++- .../server/endpoint/__init__.py | 1 + .../server/endpoint/datasources_endpoint.py | 8 +- .../server/endpoint/flow_task_endpoint.py | 29 +++ .../server/endpoint/flows_endpoint.py | 8 +- .../server/endpoint/workbooks_endpoint.py | 18 +- tableauserverclient/server/pager.py | 6 +- tableauserverclient/server/request_factory.py | 96 +++++++- tableauserverclient/server/request_options.py | 50 +++- tableauserverclient/server/server.py | 2 + test/assets/schedule_get.xml | 1 + test/assets/tasks_create_flow_task.xml | 28 +++ test/assets/tasks_with_interval.xml | 20 ++ ...workbook_get_by_id_acceleration_status.xml | 19 ++ test/assets/workbook_publish.xml | 4 +- .../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_flowtask.py | 47 ++++ test/test_request_option.py | 20 ++ test/test_schedule.py | 10 + test/test_task.py | 10 + test/test_view.py | 29 +++ test/test_view_acceleration.py | 119 ++++++++++ test/test_workbook.py | 3 + 43 files changed, 1428 insertions(+), 38 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 tableauserverclient/server/endpoint/flow_task_endpoint.py create mode 100644 test/assets/tasks_create_flow_task.xml create mode 100644 test/assets/tasks_with_interval.xml 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_flowtask.py create mode 100644 test/test_view_acceleration.py diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 6b1629bfd..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: @@ -8,7 +13,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 }} 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 = [ 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/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 diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index 7c801a4b5..ce31b1428 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -1,6 +1,7 @@ import datetime import re from functools import wraps +from typing import Any, Container, Optional, Tuple from tableauserverclient.datetime_helpers import parse_datetime @@ -65,7 +66,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 +90,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) @@ -144,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/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/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/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 86a9a2f18..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 @@ -91,6 +93,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 @@ -162,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 @@ -171,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: @@ -217,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( @@ -235,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 @@ -258,6 +279,7 @@ def _set_values( tags, views, data_acceleration_config, + data_freshness_policy, ): if id is not None: self._id = id @@ -286,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"]: @@ -356,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, @@ -372,6 +401,7 @@ def _parse_element(workbook_xml, ns): tags, views, data_acceleration_config, + data_freshness_policy, ) 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/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 66ad9f710..28226d280 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(failobj="") 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/flow_task_endpoint.py b/tableauserverclient/server/endpoint/flow_task_endpoint.py new file mode 100644 index 000000000..eea3f9710 --- /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.FlowTask.create_flow_task_req(flow_item) + server_response = self.post_request(url, create_req) + return server_response.content diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 21c16b1cc..77b01c478 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(failobj="") 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..bc535b2d6 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 @@ -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)) @@ -483,14 +491,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(failobj="") 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/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: diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 6316527ec..c204e7217 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: @@ -911,6 +916,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 @@ -941,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) @@ -1061,6 +1114,40 @@ def create_extract_req(self, xml_request: ET.Element, extract_item: "TaskItem") return ET.tostring(xml_request) +class FlowTaskRequest(object): + @_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 def create_req(self, xml_request: ET.Element, subscription_item: "SubscriptionItem") -> bytes: @@ -1200,6 +1287,7 @@ class RequestFactory(object): Favorite = FavoriteRequest() Fileupload = FileuploadRequest() Flow = FlowRequest() + FlowTask = FlowTaskRequest() Group = GroupRequest() Metric = MetricRequest() Permission = PermissionRequest() diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 95233f8fc..5cc06bf9d 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,3 +1,7 @@ +import sys + +from typing_extensions import Self + from tableauserverclient.models.property_decorators import property_is_int import logging @@ -152,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): @@ -261,11 +275,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 +292,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 +321,16 @@ 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") + + 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/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/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/assets/tasks_create_flow_task.xml b/test/assets/tasks_create_flow_task.xml new file mode 100644 index 000000000..11c9a4ff0 --- /dev/null +++ b/test/assets/tasks_create_flow_task.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + \ No newline at end of file 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/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_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/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_flowtask.py b/test/test_flowtask.py new file mode 100644 index 000000000..034066e64 --- /dev/null +++ b/test/test_flowtask.py @@ -0,0 +1,47 @@ +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_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" + + 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( + "Monthly Schedule", + 50, + TSC.ScheduleItem.Type.Flow, + TSC.ScheduleItem.ExecutionOrder.Parallel, + monthly_interval, + ) + target_item = TSC.Target("flow_id", "flow") + + 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") + 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("schedule_id" in create_response_content) + self.assertTrue("flow_id" in create_response_content) 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"]) 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") 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) 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() 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"]) 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 ffcb78601c13ebd0dca39a9b063ffc8776fe7d0f Mon Sep 17 00:00:00 2001 From: "ivan.baldinotti@digitecgalaxus.ch" Date: Wed, 5 Jun 2024 20:04:50 +0200 Subject: [PATCH 129/296] Adding name property to jobItem Class --- tableauserverclient/models/job_item.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 61e7a8d18..39f22bf03 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -33,6 +33,7 @@ def __init__( datasource_id: Optional[str] = None, flow_run: Optional[FlowRunItem] = None, updated_at: Optional[datetime.datetime] = None, + workbook_name: Optional[str] = None, ): self._id = id_ self._type = job_type @@ -47,6 +48,7 @@ def __init__( self._datasource_id = datasource_id self._flow_run = flow_run self._updated_at = updated_at + self._workbook_name = workbook_name @property def id(self) -> str: @@ -117,6 +119,10 @@ def flow_run(self, value): def updated_at(self) -> Optional[datetime.datetime]: return self._updated_at + @property + def workbook_name(self) -> Optional[str]: + return self._workbook_name + def __str__(self): return ( " Date: Thu, 6 Jun 2024 08:37:48 +0200 Subject: [PATCH 130/296] Adding test for workbook name --- test/test_job.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/test_job.py b/test/test_job.py index 83edadaef..befed08b4 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -120,3 +120,11 @@ def test_get_job_workbook_id(self) -> None: m.get(f"{self.baseurl}/{job_id}", text=response_xml) job = self.server.jobs.get_by_id(job_id) self.assertEqual(job.workbook_id, "5998aaaf-1abe-4d38-b4d9-bc53e85bdd13") + + def test_get_job_workbook_name(self) -> None: + response_xml = read_xml_asset(GET_BY_ID_WORKBOOK) + job_id = "bb1aab79-db54-4e96-9dd3-461d8f081d08" + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{job_id}", text=response_xml) + job = self.server.jobs.get_by_id(job_id) + self.assertEqual(job.workbook_name, "Superstore") 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 131/296] 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 132/296] 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 133/296] 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 134/296] 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 135/296] 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 136/296] 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 137/296] 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 138/296] 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 139/296] 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 140/296] 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 141/296] 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 142/296] 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 9daf4e3e1b7c2f73b967c3b6822233b456765308 Mon Sep 17 00:00:00 2001 From: "ivan.baldinotti@digitecgalaxus.ch" Date: Fri, 7 Jun 2024 17:42:35 +0200 Subject: [PATCH 143/296] Adding datasource name attribute to job item --- tableauserverclient/models/job_item.py | 8 ++++++++ test/test_job.py | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 39f22bf03..9933d7f29 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -34,6 +34,7 @@ def __init__( flow_run: Optional[FlowRunItem] = None, updated_at: Optional[datetime.datetime] = None, workbook_name: Optional[str] = None, + datasource_name: Optional[str] = None, ): self._id = id_ self._type = job_type @@ -49,6 +50,7 @@ def __init__( self._flow_run = flow_run self._updated_at = updated_at self._workbook_name = workbook_name + self._datasource_name = datasource_name @property def id(self) -> str: @@ -123,6 +125,10 @@ def updated_at(self) -> Optional[datetime.datetime]: def workbook_name(self) -> Optional[str]: return self._workbook_name + @property + def datasource_name(self) -> Optional[str]: + return self._datasource_name + def __str__(self): return ( " None: m.get(f"{self.baseurl}/{job_id}", text=response_xml) job = self.server.jobs.get_by_id(job_id) self.assertEqual(job.workbook_name, "Superstore") + + def test_get_job_datasource_name(self) -> None: + response_xml = read_xml_asset(GET_BY_ID_FAILED_XML) + job_id = "777bf7c4-421d-4b2c-a518-11b90187c545" + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{job_id}", text=response_xml) + job = self.server.jobs.get_by_id(job_id) + self.assertEqual(job.datasource_name, "World Indicators") 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 144/296] 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 145/296] 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 146/296] 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 147/296] 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 148/296] 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 149/296] 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 150/296] 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 151/296] 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 152/296] 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 153/296] 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 154/296] 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 155/296] 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 2e4e3c05fa1d709cfe5f5cb90751cedc8cd07bb5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Jun 2024 22:23:39 +0000 Subject: [PATCH 156/296] Bump urllib3 from 2.0.7 to 2.2.2 Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.0.7 to 2.2.2. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/2.0.7...2.2.2) --- updated-dependencies: - dependency-name: urllib3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fceb37237..ff76300a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ '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.7', # latest as at 7/31/23 + 'urllib3==2.2.2', # latest as at 7/31/23 'typing_extensions>=4.0.1', ] requires-python = ">=3.7" 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 157/296] 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 158/296] 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 159/296] 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 160/296] 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 55fa24cf84f9964bd6eaa44c2a86838afc2cd145 Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 19 Jun 2024 18:16:51 -0700 Subject: [PATCH 161/296] Update pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ff76300a7..202aed968 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ '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.2.2', # latest as at 7/31/23 + 'urllib3==2.2.2', # dependabot 'typing_extensions>=4.0.1', ] requires-python = ">=3.7" 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 162/296] 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 163/296] 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 164/296] 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 165/296] 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 166/296] 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 167/296] 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 168/296] 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 169/296] 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 170/296] 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 ea53460af11414964b04de74e300bc55f44c408f Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 27 Jun 2024 22:23:06 -0500 Subject: [PATCH 171/296] chore: make auth endpoint imports absolute --- tableauserverclient/server/endpoint/auth_endpoint.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 0b6bac0c9..468d469a7 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -4,9 +4,9 @@ from defusedxml.ElementTree import fromstring -from .endpoint import Endpoint, api -from .exceptions import ServerResponseError -from ..request_factory import RequestFactory +from tableauserverclient.server.endpoint.endpoint import Endpoint, api +from tableauserverclient.server.endpoint.exceptions import ServerResponseError +from tableauserverclient.server.request_factory import RequestFactory from tableauserverclient.helpers.logging import logger From 3a3f15624c60f3cc19d96f71497b55885517a463 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 28 Jun 2024 21:48:14 -0500 Subject: [PATCH 172/296] feat: enable bulk add and remove users --- .../server/endpoint/groups_endpoint.py | 55 ++++++++++++++----- tableauserverclient/server/request_factory.py | 24 +++++++- test/assets/group_add_users.xml | 8 +++ test/test_group.py | 49 +++++++++++++++++ 4 files changed, 121 insertions(+), 15 deletions(-) create mode 100644 test/assets/group_add_users.xml diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index 2ee9fe0ab..8c1fe02a7 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -1,17 +1,17 @@ import logging -from .endpoint import QuerysetEndpoint, api -from .exceptions import MissingRequiredFieldError +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api +from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError from tableauserverclient.server import RequestFactory from tableauserverclient.models import GroupItem, UserItem, PaginationItem, JobItem -from ..pager import Pager +from tableauserverclient.server.pager import Pager from tableauserverclient.helpers.logging import logger -from typing import List, Optional, TYPE_CHECKING, Tuple, Union +from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union if TYPE_CHECKING: - from ..request_options import RequestOptions + from tableauserverclient.server.request_options import RequestOptions class Groups(QuerysetEndpoint[GroupItem]): @@ -19,9 +19,9 @@ class Groups(QuerysetEndpoint[GroupItem]): def baseurl(self) -> str: return "{0}/sites/{1}/groups".format(self.parent_srv.baseurl, self.parent_srv.site_id) - # Gets all groups @api(version="2.0") def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[GroupItem], PaginationItem]: + """Gets all groups""" logger.info("Querying all groups on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -29,9 +29,9 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[Grou all_group_items = GroupItem.from_response(server_response.content, self.parent_srv.namespace) return all_group_items, pagination_item - # Gets all users in a given group @api(version="2.0") - def populate_users(self, group_item, req_options: Optional["RequestOptions"] = None) -> None: + def populate_users(self, group_item: GroupItem, req_options: Optional["RequestOptions"] = None) -> None: + """Gets all users in a given group""" if not group_item.id: error = "Group item missing ID. Group must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -47,7 +47,7 @@ def user_pager(): group_item._set_users(user_pager) def _get_users_for_group( - self, group_item, req_options: Optional["RequestOptions"] = None + self, group_item: GroupItem, req_options: Optional["RequestOptions"] = None ) -> Tuple[List[UserItem], PaginationItem]: url = "{0}/{1}/users".format(self.baseurl, group_item.id) server_response = self.get_request(url, req_options) @@ -56,9 +56,9 @@ def _get_users_for_group( logger.info("Populated users for group (ID: {0})".format(group_item.id)) return user_item, pagination_item - # Deletes 1 group by id @api(version="2.0") def delete(self, group_id: str) -> None: + """Deletes 1 group by id""" if not group_id: error = "Group ID undefined." raise ValueError(error) @@ -87,17 +87,17 @@ def update(self, group_item: GroupItem, as_job: bool = False) -> Union[GroupItem else: return GroupItem.from_response(server_response.content, self.parent_srv.namespace)[0] - # Create a 'local' Tableau group @api(version="2.0") def create(self, group_item: GroupItem) -> GroupItem: + """Create a 'local' Tableau group""" url = self.baseurl create_req = RequestFactory.Group.create_local_req(group_item) server_response = self.post_request(url, create_req) return GroupItem.from_response(server_response.content, self.parent_srv.namespace)[0] - # Create a group based on Active Directory @api(version="2.0") def create_AD_group(self, group_item: GroupItem, asJob: bool = False) -> Union[GroupItem, JobItem]: + """Create a group based on Active Directory""" asJobparameter = "?asJob=true" if asJob else "" url = self.baseurl + asJobparameter create_req = RequestFactory.Group.create_ad_req(group_item) @@ -107,9 +107,9 @@ def create_AD_group(self, group_item: GroupItem, asJob: bool = False) -> Union[G else: return GroupItem.from_response(server_response.content, self.parent_srv.namespace)[0] - # Removes 1 user from 1 group @api(version="2.0") def remove_user(self, group_item: GroupItem, user_id: str) -> None: + """Removes 1 user from 1 group""" if not group_item.id: error = "Group item missing ID." raise MissingRequiredFieldError(error) @@ -120,9 +120,22 @@ def remove_user(self, group_item: GroupItem, user_id: str) -> None: self.delete_request(url) logger.info("Removed user (id: {0}) from group (ID: {1})".format(user_id, group_item.id)) - # Adds 1 user to 1 group + @api(version="3.21") + def remove_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> None: + """Removes multiple users from 1 group""" + group_id = group_item.id if hasattr(group_item, "id") else group_item + if not isinstance(group_id, str): + raise ValueError(f"Invalid group provided: {group_item}") + + url = f"{self.baseurl}/{group_id}/users/remove" + add_req = RequestFactory.Group.remove_users_req(users) + _ = self.put_request(url, add_req) + logger.info("Removed users to group (ID: {0})".format(group_item.id)) + return None + @api(version="2.0") def add_user(self, group_item: GroupItem, user_id: str) -> UserItem: + """Adds 1 user to 1 group""" if not group_item.id: error = "Group item missing ID." raise MissingRequiredFieldError(error) @@ -135,3 +148,17 @@ def add_user(self, group_item: GroupItem, user_id: str) -> UserItem: user = UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() logger.info("Added user (id: {0}) to group (ID: {1})".format(user_id, group_item.id)) return user + + @api(version="3.21") + def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> List[UserItem]: + """Adds multiple users to 1 group""" + group_id = group_item.id if hasattr(group_item, "id") else group_item + if not isinstance(group_id, str): + raise ValueError(f"Invalid group provided: {group_item}") + + url = f"{self.baseurl}/{group_id}/users" + add_req = RequestFactory.Group.add_users_req(users) + server_response = self.post_request(url, add_req) + users = UserItem.from_response(server_response.content, self.parent_srv.namespace) + logger.info("Added users to group (ID: {0})".format(group_item.id)) + return users diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 87438ecde..7bf2118fd 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1,5 +1,5 @@ import xml.etree.ElementTree as ET -from typing import Any, Dict, Iterable, List, Optional, Tuple, TYPE_CHECKING +from typing import Any, Dict, Iterable, List, Optional, Tuple, TYPE_CHECKING, Union from requests.packages.urllib3.fields import RequestField from requests.packages.urllib3.filepost import encode_multipart_formdata @@ -387,6 +387,28 @@ def add_user_req(self, user_id: str) -> bytes: user_element.attrib["id"] = user_id return ET.tostring(xml_request) + @_tsrequest_wrapped + def add_users_req(self, xml_request, users: Iterable[Union[str, UserItem]]) -> bytes: + users_element = ET.SubElement(xml_request, "users") + for user in users: + user_element = ET.SubElement(users_element, "user") + if not (user_id := user.id if isinstance(user, UserItem) else user): + raise ValueError("User ID must be populated") + user_element.attrib["id"] = user_id + + return ET.tostring(xml_request) + + @_tsrequest_wrapped + def remove_users_req(self, xml_request, users: Iterable[Union[str, UserItem]]) -> bytes: + users_element = ET.SubElement(xml_request, "users") + for user in users: + user_element = ET.SubElement(users_element, "user") + if not (user_id := user.id if isinstance(user, UserItem) else user): + raise ValueError("User ID must be populated") + user_element.attrib["id"] = user_id + + return ET.tostring(xml_request) + def create_local_req(self, group_item: GroupItem) -> bytes: xml_request = ET.Element("tsRequest") group_element = ET.SubElement(xml_request, "group") diff --git a/test/assets/group_add_users.xml b/test/assets/group_add_users.xml new file mode 100644 index 000000000..23fd7bd9f --- /dev/null +++ b/test/assets/group_add_users.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/test/test_group.py b/test/test_group.py index 1edc50555..fc9c75a6d 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -14,6 +14,7 @@ POPULATE_USERS = os.path.join(TEST_ASSET_DIR, "group_populate_users.xml") POPULATE_USERS_EMPTY = os.path.join(TEST_ASSET_DIR, "group_populate_users_empty.xml") ADD_USER = os.path.join(TEST_ASSET_DIR, "group_add_user.xml") +ADD_USERS = TEST_ASSET_DIR / "group_add_users.xml" ADD_USER_POPULATE = os.path.join(TEST_ASSET_DIR, "group_users_added.xml") CREATE_GROUP = os.path.join(TEST_ASSET_DIR, "group_create.xml") CREATE_GROUP_AD = os.path.join(TEST_ASSET_DIR, "group_create_ad.xml") @@ -123,6 +124,54 @@ def test_add_user(self) -> None: self.assertEqual("testuser", user.name) self.assertEqual("ServerAdministrator", user.site_role) + def test_add_users(self) -> None: + self.server.version = "3.21" + self.baseurl = self.server.groups.baseurl + + def make_user(id: str, name: str, siteRole: str) -> TSC.UserItem: + user = TSC.UserItem(name, siteRole) + user._id = id + return user + + users = [ + make_user(id="5de011f8-4aa9-4d5b-b991-f464c8dd6bb7", name="Alice", siteRole="ServerAdministrator"), + make_user(id="5de011f8-3aa9-4d5b-b991-f467c8dd6bb8", name="Bob", siteRole="Explorer"), + make_user(id="5de011f8-2aa9-4d5b-b991-f466c8dd6bb8", name="Charlie", siteRole="Viewer"), + ] + group = TSC.GroupItem("test") + group._id = "e7833b48-c6f7-47b5-a2a7-36e7dd232758" + + with requests_mock.mock() as m: + m.post(f"{self.baseurl}/{group.id}/users", text=ADD_USERS.read_text()) + resp_users = self.server.groups.add_users(group, users) + + for user, resp_user in zip(users, resp_users): + with self.subTest(user=user, resp_user=resp_user): + assert user.id == resp_user.id + assert user.name == resp_user.name + assert user.site_role == resp_user.site_role + + def test_remove_users(self) -> None: + self.server.version = "3.21" + self.baseurl = self.server.groups.baseurl + + def make_user(id: str, name: str, siteRole: str) -> TSC.UserItem: + user = TSC.UserItem(name, siteRole) + user._id = id + return user + + users = [ + make_user(id="5de011f8-4aa9-4d5b-b991-f464c8dd6bb7", name="Alice", siteRole="ServerAdministrator"), + make_user(id="5de011f8-3aa9-4d5b-b991-f467c8dd6bb8", name="Bob", siteRole="Explorer"), + make_user(id="5de011f8-2aa9-4d5b-b991-f466c8dd6bb8", name="Charlie", siteRole="Viewer"), + ] + group = TSC.GroupItem("test") + group._id = "e7833b48-c6f7-47b5-a2a7-36e7dd232758" + + with requests_mock.mock() as m: + m.put(f"{self.baseurl}/{group.id}/users/remove") + self.server.groups.remove_users(group, users) + def test_add_user_before_populating(self) -> None: with open(GET_XML, "rb") as f: get_xml_response = f.read().decode("utf-8") From 8f0609d1a2fa06b6fb29ad0fce97755943958ba0 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Tue, 23 Jul 2024 22:33:16 -0500 Subject: [PATCH 173/296] feat: parse linked task xml --- .../models/linked_tasks_item.py | 74 +++++++++++++++++++ .../server/endpoint/linked_tasks_endpoint.py | 17 +++++ test/assets/linked_tasks_get.xml | 33 +++++++++ test/test_linked_tasks.py | 62 ++++++++++++++++ 4 files changed, 186 insertions(+) create mode 100644 tableauserverclient/models/linked_tasks_item.py create mode 100644 tableauserverclient/server/endpoint/linked_tasks_endpoint.py create mode 100644 test/assets/linked_tasks_get.xml create mode 100644 test/test_linked_tasks.py diff --git a/tableauserverclient/models/linked_tasks_item.py b/tableauserverclient/models/linked_tasks_item.py new file mode 100644 index 000000000..995782218 --- /dev/null +++ b/tableauserverclient/models/linked_tasks_item.py @@ -0,0 +1,74 @@ +from typing import List, Optional +from defusedxml.ElementTree import fromstring + +from tableauserverclient.models.schedule_item import ScheduleItem +from tableauserverclient.models.task_item import TaskItem + +class LinkedTaskItem: + def __init__(self) -> None: + self.id: Optional[str] = None + self.num_steps: Optional[int] = None + self.schedule: Optional[ScheduleItem] = None + + @classmethod + def from_response(cls, resp: bytes, namespace) -> List["LinkedTaskItem"]: + parsed_response = fromstring(resp) + return [cls._parse_element(x, namespace) for x in parsed_response.findall(".//t:linkedTasks[@id]", namespaces=namespace)] + + @classmethod + def _parse_element(cls, xml, namespace) -> "LinkedTaskItem": + task = cls() + task.id = xml.get("id") + task.num_steps = int(xml.get("numSteps")) + task.schedule = ScheduleItem.from_element(xml, namespace)[0] + return task + +class LinkedTaskStepItem: + def __init__(self) -> None: + self.id: Optional[str] = None + self.step_number: Optional[int] = None + self.stop_downstream_on_failure: Optional[bool] = None + self.task_details: List[LinkedTaskFlowRunItem] = [] + + @classmethod + def from_task_xml(cls, xml, namespace) -> List["LinkedTaskStepItem"]: + return [cls._parse_element(x, namespace) for x in xml.findall(".//t:linkedTaskSteps[@id]", namespace)] + + @classmethod + def _parse_element(cls, xml, namespace) -> "LinkedTaskStepItem": + step = cls() + step.id = xml.get("id") + step.step_number = int(xml.get("stepNumber")) + step.stop_downstream_on_failure = string_to_bool(xml.get("stopDownstreamTasksOnFailure")) + step.task_details = LinkedTaskFlowRunItem._parse_element(xml, namespace) + return step + +class LinkedTaskFlowRunItem: + def __init__(self) -> None: + self.flow_run_id: Optional[str] = None + self.flow_run_priority: Optional[int] = None + self.flow_run_consecutive_failed_count: Optional[int] = None + self.flow_run_task_type: Optional[str] = None + self.flow_id: Optional[str] = None + self.flow_name: Optional[str] = None + + @classmethod + def _parse_element(cls, xml, namespace) -> List["LinkedTaskFlowRunItem"]: + all_tasks = [] + for flow_run in xml.findall(".//t:flowRun[@id]", namespace): + task = cls() + task.flow_run_id = flow_run.get("id") + task.flow_run_priority = int(flow_run.get("priority")) + task.flow_run_consecutive_failed_count = int(flow_run.get("consecutiveFailedCount")) + task.flow_run_task_type = flow_run.get("type") + flow = flow_run.find(".//t:flow[@id]", namespace) + task.flow_id = flow.get("id") + task.flow_name = flow.get("name") + all_tasks.append(task) + + return all_tasks + + + +def string_to_bool(s: str) -> bool: + return s.lower() == "true" diff --git a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py new file mode 100644 index 000000000..657592d35 --- /dev/null +++ b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py @@ -0,0 +1,17 @@ +from typing import Optional +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api +from tableauserverclient.server.request_options import RequestOptions + +class LinkedTasks(QuerysetEndpoint): + def __init__(self, parent_srv): + super().__init__(parent_srv) + self._parent_srv = parent_srv + + @property + def baseurl(self): + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks/linked" + + @api(version="3.15") + def get(self, req_options: Optional[RequestOptions] = None): + ... + diff --git a/test/assets/linked_tasks_get.xml b/test/assets/linked_tasks_get.xml new file mode 100644 index 000000000..23b7bbbbc --- /dev/null +++ b/test/assets/linked_tasks_get.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + diff --git a/test/test_linked_tasks.py b/test/test_linked_tasks.py new file mode 100644 index 000000000..e9973906b --- /dev/null +++ b/test/test_linked_tasks.py @@ -0,0 +1,62 @@ +from pathlib import Path +import unittest + +from defusedxml.ElementTree import fromstring +import pytest + +import tableauserverclient as TSC +from tableauserverclient.models.linked_tasks_item import LinkedTaskItem, LinkedTaskStepItem, LinkedTaskFlowRunItem + +asset_dir = (Path(__file__).parent / "assets").resolve() + +GET_LINKED_TASKS = asset_dir / "linked_tasks_get.xml" + +class TestLinkedTasks(unittest.TestCase): + + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) + + # Fake signin + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + + # self.baseurl = self.server.linked_tasks.baseurl + + def test_parse_linked_task_flow_run(self): + xml = fromstring(GET_LINKED_TASKS.read_bytes()) + task_runs = LinkedTaskFlowRunItem._parse_element(xml, self.server.namespace) + self.assertEqual(1, len(task_runs)) + task = task_runs[0] + self.assertEqual(task.flow_run_id, "e3d1fc25-5644-4e32-af35-58dcbd1dbd73") + self.assertEqual(task.flow_run_priority, 1) + self.assertEqual(task.flow_run_consecutive_failed_count, 3) + self.assertEqual(task.flow_run_task_type, "runFlow") + self.assertEqual(task.flow_id, "ab1231eb-b8ca-461e-a131-83f3c2b6a673") + self.assertEqual(task.flow_name, "flow-name") + + + def test_parse_linked_task_step(self): + xml = fromstring(GET_LINKED_TASKS.read_bytes()) + steps = LinkedTaskStepItem.from_task_xml(xml, self.server.namespace) + self.assertEqual(1, len(steps)) + step = steps[0] + self.assertEqual(step.id, "f554a4df-bb6f-4294-94ee-9a709ef9bda0") + self.assertTrue(step.stop_downstream_on_failure) + self.assertEqual(step.step_number, 1) + self.assertEqual(1, len(step.task_details)) + task = step.task_details[0] + self.assertEqual(task.flow_run_id, "e3d1fc25-5644-4e32-af35-58dcbd1dbd73") + self.assertEqual(task.flow_run_priority, 1) + self.assertEqual(task.flow_run_consecutive_failed_count, 3) + self.assertEqual(task.flow_run_task_type, "runFlow") + self.assertEqual(task.flow_id, "ab1231eb-b8ca-461e-a131-83f3c2b6a673") + self.assertEqual(task.flow_name, "flow-name") + + def test_parse_linked_task(self): + tasks = LinkedTaskItem.from_response(GET_LINKED_TASKS.read_bytes(), self.server.namespace) + self.assertEqual(1, len(tasks)) + task = tasks[0] + self.assertEqual(task.id, "1b8211dc-51a8-45ce-a831-b5921708e03e") + self.assertEqual(task.num_steps, 1) + self.assertEqual(task.schedule.id, "be077332-d01d-481b-b2f3-917e463d4dca") + From 92c832051be0ccca9d53aff57dc6b4c63aca5819 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Tue, 23 Jul 2024 22:52:50 -0500 Subject: [PATCH 174/296] feat: get linked tasks --- tableauserverclient/__init__.py | 7 +++++++ tableauserverclient/models/__init__.py | 8 ++++++++ .../models/linked_tasks_item.py | 1 - .../server/endpoint/__init__.py | 2 ++ .../server/endpoint/linked_tasks_endpoint.py | 19 ++++++++++++++----- tableauserverclient/server/server.py | 2 ++ test/test_linked_tasks.py | 16 +++++++++++++++- 7 files changed, 48 insertions(+), 7 deletions(-) diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 91205d810..65439690f 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -21,6 +21,9 @@ IntervalItem, JobItem, JWTAuth, + LinkedTaskItem, + LinkedTaskStepItem, + LinkedTaskFlowRunItem, MetricItem, MonthlyInterval, PaginationItem, @@ -116,4 +119,8 @@ "Pager", "Server", "Sort", + "LinkedTaskItem", + "LinkedTaskStepItem", + "LinkedTaskFlowRunItem", + ] diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 5fdf3c2c3..fa4153154 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -22,6 +22,11 @@ HourlyInterval, ) from tableauserverclient.models.job_item import JobItem, BackgroundJobItem +from tableauserverclient.models.linked_tasks_item import ( + LinkedTaskItem, + LinkedTaskStepItem, + LinkedTaskFlowRunItem, +) from tableauserverclient.models.metric_item import MetricItem from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.models.permissions_item import PermissionsRule, Permission @@ -91,4 +96,7 @@ "ViewItem", "WebhookItem", "WorkbookItem", + "LinkedTaskItem", + "LinkedTaskStepItem", + "LinkedTaskFlowRunItem", ] diff --git a/tableauserverclient/models/linked_tasks_item.py b/tableauserverclient/models/linked_tasks_item.py index 995782218..1367cb158 100644 --- a/tableauserverclient/models/linked_tasks_item.py +++ b/tableauserverclient/models/linked_tasks_item.py @@ -2,7 +2,6 @@ from defusedxml.ElementTree import fromstring from tableauserverclient.models.schedule_item import ScheduleItem -from tableauserverclient.models.task_item import TaskItem class LinkedTaskItem: def __init__(self) -> None: diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index 024350aaa..fb22302f2 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -13,6 +13,7 @@ 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.linked_tasks_endpoint import LinkedTasks from tableauserverclient.server.endpoint.metadata_endpoint import Metadata from tableauserverclient.server.endpoint.metrics_endpoint import Metrics from tableauserverclient.server.endpoint.projects_endpoint import Projects @@ -44,6 +45,7 @@ "FlowTasks", "Groups", "Jobs", + "LinkedTasks", "Metadata", "Metrics", "Projects", diff --git a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py index 657592d35..df731a6b4 100644 --- a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py @@ -1,17 +1,26 @@ -from typing import Optional +from typing import List, Optional, Tuple + +from tableauserverclient.helpers.logging import logger +from tableauserverclient.models.linked_tasks_item import LinkedTaskItem +from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api from tableauserverclient.server.request_options import RequestOptions -class LinkedTasks(QuerysetEndpoint): +class LinkedTasks(QuerysetEndpoint[LinkedTaskItem]): def __init__(self, parent_srv): super().__init__(parent_srv) self._parent_srv = parent_srv @property - def baseurl(self): + def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks/linked" @api(version="3.15") - def get(self, req_options: Optional[RequestOptions] = None): - ... + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[LinkedTaskItem], PaginationItem]: + logger.info("Querying all linked tasks on site") + url = self.baseurl + server_response = self.get_request(url, req_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + all_group_items = LinkedTaskItem.from_response(server_response.content, self.parent_srv.namespace) + return all_group_items, pagination_item diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 10b1a53ad..c2eaabb85 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -33,6 +33,7 @@ Metrics, Endpoint, CustomViews, + LinkedTasks, ) from tableauserverclient.server.exceptions import ( ServerInfoEndpointNotFoundError, @@ -99,6 +100,7 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self.flow_runs = FlowRuns(self) self.metrics = Metrics(self) self.custom_views = CustomViews(self) + self.linked_tasks = LinkedTasks(self) self._session = self._session_factory() self._http_options = dict() # must set this before making a server call diff --git a/test/test_linked_tasks.py b/test/test_linked_tasks.py index e9973906b..50999abe7 100644 --- a/test/test_linked_tasks.py +++ b/test/test_linked_tasks.py @@ -3,6 +3,7 @@ from defusedxml.ElementTree import fromstring import pytest +import requests_mock import tableauserverclient as TSC from tableauserverclient.models.linked_tasks_item import LinkedTaskItem, LinkedTaskStepItem, LinkedTaskFlowRunItem @@ -15,12 +16,13 @@ class TestLinkedTasks(unittest.TestCase): def setUp(self) -> None: self.server = TSC.Server("http://test", False) + self.server.version = "3.15" # Fake signin self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - # self.baseurl = self.server.linked_tasks.baseurl + self.baseurl = self.server.linked_tasks.baseurl def test_parse_linked_task_flow_run(self): xml = fromstring(GET_LINKED_TASKS.read_bytes()) @@ -60,3 +62,15 @@ def test_parse_linked_task(self): self.assertEqual(task.num_steps, 1) self.assertEqual(task.schedule.id, "be077332-d01d-481b-b2f3-917e463d4dca") + def test_get_linked_tasks(self): + with requests_mock.mock() as m: + m.get(self.baseurl, text=GET_LINKED_TASKS.read_text()) + tasks, pagination_item = self.server.linked_tasks.get() + + self.assertEqual(1, len(tasks)) + task = tasks[0] + self.assertEqual(task.id, "1b8211dc-51a8-45ce-a831-b5921708e03e") + self.assertEqual(task.num_steps, 1) + self.assertEqual(task.schedule.id, "be077332-d01d-481b-b2f3-917e463d4dca") + + From 605625541edcfb75c7c02b8073f2544dbe6a87ea Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Tue, 23 Jul 2024 22:53:39 -0500 Subject: [PATCH 175/296] style: black --- tableauserverclient/__init__.py | 1 - tableauserverclient/models/linked_tasks_item.py | 11 ++++++++--- .../server/endpoint/linked_tasks_endpoint.py | 2 +- test/test_linked_tasks.py | 5 +---- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 65439690f..29d462f12 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -122,5 +122,4 @@ "LinkedTaskItem", "LinkedTaskStepItem", "LinkedTaskFlowRunItem", - ] diff --git a/tableauserverclient/models/linked_tasks_item.py b/tableauserverclient/models/linked_tasks_item.py index 1367cb158..59dc65213 100644 --- a/tableauserverclient/models/linked_tasks_item.py +++ b/tableauserverclient/models/linked_tasks_item.py @@ -3,6 +3,7 @@ from tableauserverclient.models.schedule_item import ScheduleItem + class LinkedTaskItem: def __init__(self) -> None: self.id: Optional[str] = None @@ -12,7 +13,10 @@ def __init__(self) -> None: @classmethod def from_response(cls, resp: bytes, namespace) -> List["LinkedTaskItem"]: parsed_response = fromstring(resp) - return [cls._parse_element(x, namespace) for x in parsed_response.findall(".//t:linkedTasks[@id]", namespaces=namespace)] + return [ + cls._parse_element(x, namespace) + for x in parsed_response.findall(".//t:linkedTasks[@id]", namespaces=namespace) + ] @classmethod def _parse_element(cls, xml, namespace) -> "LinkedTaskItem": @@ -22,9 +26,10 @@ def _parse_element(cls, xml, namespace) -> "LinkedTaskItem": task.schedule = ScheduleItem.from_element(xml, namespace)[0] return task + class LinkedTaskStepItem: def __init__(self) -> None: - self.id: Optional[str] = None + self.id: Optional[str] = None self.step_number: Optional[int] = None self.stop_downstream_on_failure: Optional[bool] = None self.task_details: List[LinkedTaskFlowRunItem] = [] @@ -42,6 +47,7 @@ def _parse_element(cls, xml, namespace) -> "LinkedTaskStepItem": step.task_details = LinkedTaskFlowRunItem._parse_element(xml, namespace) return step + class LinkedTaskFlowRunItem: def __init__(self) -> None: self.flow_run_id: Optional[str] = None @@ -68,6 +74,5 @@ def _parse_element(cls, xml, namespace) -> List["LinkedTaskFlowRunItem"]: return all_tasks - def string_to_bool(s: str) -> bool: return s.lower() == "true" diff --git a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py index df731a6b4..ffd527344 100644 --- a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py @@ -6,6 +6,7 @@ from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api from tableauserverclient.server.request_options import RequestOptions + class LinkedTasks(QuerysetEndpoint[LinkedTaskItem]): def __init__(self, parent_srv): super().__init__(parent_srv) @@ -23,4 +24,3 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[Link pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) all_group_items = LinkedTaskItem.from_response(server_response.content, self.parent_srv.namespace) return all_group_items, pagination_item - diff --git a/test/test_linked_tasks.py b/test/test_linked_tasks.py index 50999abe7..916c86f27 100644 --- a/test/test_linked_tasks.py +++ b/test/test_linked_tasks.py @@ -12,8 +12,8 @@ GET_LINKED_TASKS = asset_dir / "linked_tasks_get.xml" + class TestLinkedTasks(unittest.TestCase): - def setUp(self) -> None: self.server = TSC.Server("http://test", False) self.server.version = "3.15" @@ -36,7 +36,6 @@ def test_parse_linked_task_flow_run(self): self.assertEqual(task.flow_id, "ab1231eb-b8ca-461e-a131-83f3c2b6a673") self.assertEqual(task.flow_name, "flow-name") - def test_parse_linked_task_step(self): xml = fromstring(GET_LINKED_TASKS.read_bytes()) steps = LinkedTaskStepItem.from_task_xml(xml, self.server.namespace) @@ -72,5 +71,3 @@ def test_get_linked_tasks(self): self.assertEqual(task.id, "1b8211dc-51a8-45ce-a831-b5921708e03e") self.assertEqual(task.num_steps, 1) self.assertEqual(task.schedule.id, "be077332-d01d-481b-b2f3-917e463d4dca") - - From 643d7657fa7f5f92959b8742778218ea6f05cdd1 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Wed, 24 Jul 2024 05:33:27 -0500 Subject: [PATCH 176/296] chore: switch to pytest style asserts --- test/test_linked_tasks.py | 54 ++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/test/test_linked_tasks.py b/test/test_linked_tasks.py index 916c86f27..16f2b0306 100644 --- a/test/test_linked_tasks.py +++ b/test/test_linked_tasks.py @@ -27,47 +27,49 @@ def setUp(self) -> None: def test_parse_linked_task_flow_run(self): xml = fromstring(GET_LINKED_TASKS.read_bytes()) task_runs = LinkedTaskFlowRunItem._parse_element(xml, self.server.namespace) - self.assertEqual(1, len(task_runs)) + assert 1 == len(task_runs) task = task_runs[0] - self.assertEqual(task.flow_run_id, "e3d1fc25-5644-4e32-af35-58dcbd1dbd73") - self.assertEqual(task.flow_run_priority, 1) - self.assertEqual(task.flow_run_consecutive_failed_count, 3) - self.assertEqual(task.flow_run_task_type, "runFlow") - self.assertEqual(task.flow_id, "ab1231eb-b8ca-461e-a131-83f3c2b6a673") - self.assertEqual(task.flow_name, "flow-name") + assert task.flow_run_id == "e3d1fc25-5644-4e32-af35-58dcbd1dbd73" + assert task.flow_run_priority == 1 + assert task.flow_run_consecutive_failed_count == 3 + assert task.flow_run_task_type == "runFlow" + assert task.flow_id == "ab1231eb-b8ca-461e-a131-83f3c2b6a673" + assert task.flow_name == "flow-name" def test_parse_linked_task_step(self): xml = fromstring(GET_LINKED_TASKS.read_bytes()) steps = LinkedTaskStepItem.from_task_xml(xml, self.server.namespace) - self.assertEqual(1, len(steps)) + assert 1 == len(steps) step = steps[0] - self.assertEqual(step.id, "f554a4df-bb6f-4294-94ee-9a709ef9bda0") - self.assertTrue(step.stop_downstream_on_failure) - self.assertEqual(step.step_number, 1) - self.assertEqual(1, len(step.task_details)) + assert step.id == "f554a4df-bb6f-4294-94ee-9a709ef9bda0" + assert step.stop_downstream_on_failure + assert step.step_number == 1 + assert 1 == len(step.task_details) task = step.task_details[0] - self.assertEqual(task.flow_run_id, "e3d1fc25-5644-4e32-af35-58dcbd1dbd73") - self.assertEqual(task.flow_run_priority, 1) - self.assertEqual(task.flow_run_consecutive_failed_count, 3) - self.assertEqual(task.flow_run_task_type, "runFlow") - self.assertEqual(task.flow_id, "ab1231eb-b8ca-461e-a131-83f3c2b6a673") - self.assertEqual(task.flow_name, "flow-name") + assert task.flow_run_id == "e3d1fc25-5644-4e32-af35-58dcbd1dbd73" + assert task.flow_run_priority == 1 + assert task.flow_run_consecutive_failed_count == 3 + assert task.flow_run_task_type == "runFlow" + assert task.flow_id == "ab1231eb-b8ca-461e-a131-83f3c2b6a673" + assert task.flow_name == "flow-name" def test_parse_linked_task(self): tasks = LinkedTaskItem.from_response(GET_LINKED_TASKS.read_bytes(), self.server.namespace) - self.assertEqual(1, len(tasks)) + assert 1 == len(tasks) task = tasks[0] - self.assertEqual(task.id, "1b8211dc-51a8-45ce-a831-b5921708e03e") - self.assertEqual(task.num_steps, 1) - self.assertEqual(task.schedule.id, "be077332-d01d-481b-b2f3-917e463d4dca") + assert task.id == "1b8211dc-51a8-45ce-a831-b5921708e03e" + assert task.num_steps == 1 + assert task.schedule is not None + assert task.schedule.id == "be077332-d01d-481b-b2f3-917e463d4dca" def test_get_linked_tasks(self): with requests_mock.mock() as m: m.get(self.baseurl, text=GET_LINKED_TASKS.read_text()) tasks, pagination_item = self.server.linked_tasks.get() - self.assertEqual(1, len(tasks)) + assert 1 == len(tasks) task = tasks[0] - self.assertEqual(task.id, "1b8211dc-51a8-45ce-a831-b5921708e03e") - self.assertEqual(task.num_steps, 1) - self.assertEqual(task.schedule.id, "be077332-d01d-481b-b2f3-917e463d4dca") + assert task.id == "1b8211dc-51a8-45ce-a831-b5921708e03e" + assert task.num_steps == 1 + assert task.schedule is not None + assert task.schedule.id == "be077332-d01d-481b-b2f3-917e463d4dca" From ddd1a71f99b1c10812fa074247e966a40234fd40 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Wed, 24 Jul 2024 05:41:27 -0500 Subject: [PATCH 177/296] feat: linked task get_by_id --- .../server/endpoint/linked_tasks_endpoint.py | 11 +++++++- test/test_linked_tasks.py | 26 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py index ffd527344..fd05b525d 100644 --- a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Tuple +from typing import List, Optional, Tuple, Union from tableauserverclient.helpers.logging import logger from tableauserverclient.models.linked_tasks_item import LinkedTaskItem @@ -24,3 +24,12 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[Link pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) all_group_items = LinkedTaskItem.from_response(server_response.content, self.parent_srv.namespace) return all_group_items, pagination_item + + @api(version="3.15") + def get_by_id(self, linked_task: Union[LinkedTaskItem, str]) -> LinkedTaskItem: + task_id = getattr(linked_task, "id", linked_task) + logger.info("Querying all linked tasks on site") + url = f"{self.baseurl}/{task_id}" + server_response = self.get_request(url) + all_group_items = LinkedTaskItem.from_response(server_response.content, self.parent_srv.namespace) + return all_group_items[0] diff --git a/test/test_linked_tasks.py b/test/test_linked_tasks.py index 16f2b0306..431045f76 100644 --- a/test/test_linked_tasks.py +++ b/test/test_linked_tasks.py @@ -73,3 +73,29 @@ def test_get_linked_tasks(self): assert task.num_steps == 1 assert task.schedule is not None assert task.schedule.id == "be077332-d01d-481b-b2f3-917e463d4dca" + + def test_get_by_id_str_linked_task(self): + id_ = "1b8211dc-51a8-45ce-a831-b5921708e03e" + + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{id_}", text=GET_LINKED_TASKS.read_text()) + task = self.server.linked_tasks.get_by_id(id_) + + assert task.id == "1b8211dc-51a8-45ce-a831-b5921708e03e" + assert task.num_steps == 1 + assert task.schedule is not None + assert task.schedule.id == "be077332-d01d-481b-b2f3-917e463d4dca" + + def test_get_by_id_obj_linked_task(self): + id_ = "1b8211dc-51a8-45ce-a831-b5921708e03e" + in_task = LinkedTaskItem() + in_task.id = id_ + + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{id_}", text=GET_LINKED_TASKS.read_text()) + task = self.server.linked_tasks.get_by_id(in_task) + + assert task.id == "1b8211dc-51a8-45ce-a831-b5921708e03e" + assert task.num_steps == 1 + assert task.schedule is not None + assert task.schedule.id == "be077332-d01d-481b-b2f3-917e463d4dca" From f8843d496e89d34e7c334ccfe89d760b2689c202 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Wed, 24 Jul 2024 06:00:04 -0500 Subject: [PATCH 178/296] feat: run linked task now --- .../models/linked_tasks_item.py | 24 ++++++++++++++++ .../server/endpoint/linked_tasks_endpoint.py | 12 +++++++- test/assets/linked_tasks_run_now.xml | 7 +++++ test/test_linked_tasks.py | 28 +++++++++++++++++++ 4 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 test/assets/linked_tasks_run_now.xml diff --git a/tableauserverclient/models/linked_tasks_item.py b/tableauserverclient/models/linked_tasks_item.py index 59dc65213..ae9b60425 100644 --- a/tableauserverclient/models/linked_tasks_item.py +++ b/tableauserverclient/models/linked_tasks_item.py @@ -1,6 +1,9 @@ +import datetime as dt from typing import List, Optional + from defusedxml.ElementTree import fromstring +from tableauserverclient.datetime_helpers import parse_datetime from tableauserverclient.models.schedule_item import ScheduleItem @@ -74,5 +77,26 @@ def _parse_element(cls, xml, namespace) -> List["LinkedTaskFlowRunItem"]: return all_tasks +class LinkedTaskJobItem: + def __init__(self) -> None: + self.id: Optional[str] = None + self.linked_task_id: Optional[str] = None + self.status: Optional[str] = None + self.created_at: Optional[dt.datetime] = None + + @classmethod + def from_response(cls, resp: bytes, namespace) -> "LinkedTaskJobItem": + parsed_response = fromstring(resp) + job = cls() + job_xml = parsed_response.find(".//t:linkedTaskJob[@id]", namespaces=namespace) + if job_xml is None: + raise ValueError("No linked task job found in response") + job.id = job_xml.get("id") + job.linked_task_id = job_xml.get("linkedTaskId") + job.status = job_xml.get("status") + job.created_at = parse_datetime(job_xml.get("createdAt")) + return job + + def string_to_bool(s: str) -> bool: return s.lower() == "true" diff --git a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py index fd05b525d..374130509 100644 --- a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py @@ -1,9 +1,10 @@ from typing import List, Optional, Tuple, Union from tableauserverclient.helpers.logging import logger -from tableauserverclient.models.linked_tasks_item import LinkedTaskItem +from tableauserverclient.models.linked_tasks_item import LinkedTaskItem, LinkedTaskJobItem from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api +from tableauserverclient.server.request_factory import RequestFactory from tableauserverclient.server.request_options import RequestOptions @@ -33,3 +34,12 @@ def get_by_id(self, linked_task: Union[LinkedTaskItem, str]) -> LinkedTaskItem: server_response = self.get_request(url) all_group_items = LinkedTaskItem.from_response(server_response.content, self.parent_srv.namespace) return all_group_items[0] + + @api(version="3.15") + def run_now(self, linked_task: Union[LinkedTaskItem, str]) -> LinkedTaskJobItem: + task_id = getattr(linked_task, "id", linked_task) + logger.info(f"Running linked task {task_id} now") + url = f"{self.baseurl}/{task_id}/runNow" + empty_req = RequestFactory.Empty.empty_req() + server_response = self.post_request(url, empty_req) + return LinkedTaskJobItem.from_response(server_response.content, self.parent_srv.namespace) diff --git a/test/assets/linked_tasks_run_now.xml b/test/assets/linked_tasks_run_now.xml new file mode 100644 index 000000000..63cef73b1 --- /dev/null +++ b/test/assets/linked_tasks_run_now.xml @@ -0,0 +1,7 @@ + + + + diff --git a/test/test_linked_tasks.py b/test/test_linked_tasks.py index 431045f76..8ea5226d7 100644 --- a/test/test_linked_tasks.py +++ b/test/test_linked_tasks.py @@ -6,11 +6,13 @@ import requests_mock import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import parse_datetime from tableauserverclient.models.linked_tasks_item import LinkedTaskItem, LinkedTaskStepItem, LinkedTaskFlowRunItem asset_dir = (Path(__file__).parent / "assets").resolve() GET_LINKED_TASKS = asset_dir / "linked_tasks_get.xml" +RUN_LINKED_TASK_NOW = asset_dir / "linked_tasks_run_now.xml" class TestLinkedTasks(unittest.TestCase): @@ -99,3 +101,29 @@ def test_get_by_id_obj_linked_task(self): assert task.num_steps == 1 assert task.schedule is not None assert task.schedule.id == "be077332-d01d-481b-b2f3-917e463d4dca" + + def test_run_now_str_linked_task(self): + id_ = "1b8211dc-51a8-45ce-a831-b5921708e03e" + + with requests_mock.mock() as m: + m.post(f"{self.baseurl}/{id_}/runNow", text=RUN_LINKED_TASK_NOW.read_text()) + job = self.server.linked_tasks.run_now(id_) + + assert job.id == "269a1e5a-1220-4a13-ac01-704982693dd8" + assert job.status == "InProgress" + assert job.created_at == parse_datetime("2022-02-15T00:22:22Z") + assert job.linked_task_id == id_ + + def test_run_now_obj_linked_task(self): + id_ = "1b8211dc-51a8-45ce-a831-b5921708e03e" + in_task = LinkedTaskItem() + in_task.id = id_ + + with requests_mock.mock() as m: + m.post(f"{self.baseurl}/{id_}/runNow", text=RUN_LINKED_TASK_NOW.read_text()) + job = self.server.linked_tasks.run_now(in_task) + + assert job.id == "269a1e5a-1220-4a13-ac01-704982693dd8" + assert job.status == "InProgress" + assert job.created_at == parse_datetime("2022-02-15T00:22:22Z") + assert job.linked_task_id == id_ From 062a55fbe52c415807d0605b6ec26772877bad88 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 28 Jun 2024 07:08:05 -0500 Subject: [PATCH 179/296] chore: make imports for flows absolute --- tableauserverclient/models/flow_item.py | 12 ++++++------ .../server/endpoint/flow_runs_endpoint.py | 8 ++++---- .../server/endpoint/flows_endpoint.py | 10 +++++----- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index d543ad8eb..edce2ec97 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -6,12 +6,12 @@ from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime -from .connection_item import ConnectionItem -from .dqw_item import DQWItem -from .exceptions import UnpopulatedPropertyError -from .permissions_item import Permission -from .property_decorators import property_not_nullable -from .tag_item import TagItem +from tableauserverclient.models.connection_item import ConnectionItem +from tableauserverclient.models.dqw_item import DQWItem +from tableauserverclient.models.exceptions import UnpopulatedPropertyError +from tableauserverclient.models.permissions_item import Permission +from tableauserverclient.models.property_decorators import property_not_nullable +from tableauserverclient.models.tag_item import TagItem class FlowItem(object): diff --git a/tableauserverclient/server/endpoint/flow_runs_endpoint.py b/tableauserverclient/server/endpoint/flow_runs_endpoint.py index ea45ce802..04aefaeee 100644 --- a/tableauserverclient/server/endpoint/flow_runs_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_runs_endpoint.py @@ -1,16 +1,16 @@ import logging from typing import List, Optional, Tuple, TYPE_CHECKING -from .endpoint import QuerysetEndpoint, api -from .exceptions import FlowRunFailedException, FlowRunCancelledException +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api +from tableauserverclient.server.endpoint.exceptions import FlowRunFailedException, FlowRunCancelledException from tableauserverclient.models import FlowRunItem, PaginationItem from tableauserverclient.exponential_backoff import ExponentialBackoffTimer from tableauserverclient.helpers.logging import logger 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 class FlowRuns(QuerysetEndpoint[FlowRunItem]): diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 2997e9456..858ff91ac 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -9,11 +9,11 @@ from tableauserverclient.helpers.headers import fix_filename -from .dqw_endpoint import _DataQualityWarningEndpoint -from .endpoint import QuerysetEndpoint, api -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 +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.models import FlowItem, PaginationItem, ConnectionItem, JobItem from tableauserverclient.server import RequestFactory from tableauserverclient.filesys_helpers import ( From 1745bf685d1ad8ce56be2a2b3534502ba6edc3be Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Tue, 25 Jun 2024 21:17:04 -0500 Subject: [PATCH 180/296] feat: add support for groupsets --- tableauserverclient/__init__.py | 2 + tableauserverclient/models/__init__.py | 2 + tableauserverclient/models/groupset_item.py | 46 +++++++ .../server/endpoint/__init__.py | 2 + .../server/endpoint/groupsets_endpoint.py | 87 ++++++++++++ tableauserverclient/server/request_factory.py | 17 +++ tableauserverclient/server/server.py | 2 + test/assets/groupsets_create.xml | 4 + test/assets/groupsets_get.xml | 15 ++ test/assets/groupsets_get_by_id.xml | 9 ++ test/assets/groupsets_update.xml | 9 ++ test/test_groupsets.py | 130 ++++++++++++++++++ 12 files changed, 325 insertions(+) create mode 100644 tableauserverclient/models/groupset_item.py create mode 100644 tableauserverclient/server/endpoint/groupsets_endpoint.py create mode 100644 test/assets/groupsets_create.xml create mode 100644 test/assets/groupsets_get.xml create mode 100644 test/assets/groupsets_get_by_id.xml create mode 100644 test/assets/groupsets_update.xml create mode 100644 test/test_groupsets.py diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 91205d810..22a0854ee 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -17,6 +17,7 @@ FlowRunItem, FileuploadItem, GroupItem, + GroupSetItem, HourlyInterval, IntervalItem, JobItem, @@ -79,6 +80,7 @@ "FlowRunItem", "FileuploadItem", "GroupItem", + "GroupSetItem", "HourlyInterval", "IntervalItem", "JobItem", diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 5fdf3c2c3..de0c516b7 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -14,6 +14,7 @@ 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.groupset_item import GroupSetItem from tableauserverclient.models.interval_item import ( IntervalItem, DailyInterval, @@ -60,6 +61,7 @@ "FlowItem", "FlowRunItem", "GroupItem", + "GroupSetItem", "IntervalItem", "JobItem", "DailyInterval", diff --git a/tableauserverclient/models/groupset_item.py b/tableauserverclient/models/groupset_item.py new file mode 100644 index 000000000..9df87ef3f --- /dev/null +++ b/tableauserverclient/models/groupset_item.py @@ -0,0 +1,46 @@ +from typing import Dict, List, Optional +import xml.etree.ElementTree as ET + +from defusedxml.ElementTree import fromstring + +from tableauserverclient.models.group_item import GroupItem + + +class GroupSetItem: + def __init__(self, name: Optional[str] = None) -> None: + self.name = name + self.id: Optional[str] = None + self.groups: List["GroupItem"] = [] + self.group_count: int = 0 + + def __str__(self) -> str: + name = self.name + id = self.id + return f"<{self.__class__.__qualname__}({name=}, {id=})>" + + def __repr__(self) -> str: + return self.__str__() + + @classmethod + def from_response(cls, response: bytes, ns: Dict[str, str]) -> List["GroupSetItem"]: + parsed_response = fromstring(response) + all_groupset_xml = parsed_response.findall(".//t:groupSet", namespaces=ns) + return [cls.from_xml(xml, ns) for xml in all_groupset_xml] + + @classmethod + def from_xml(cls, groupset_xml: ET.Element, ns: Dict[str, str]) -> "GroupSetItem": + def get_group(group_xml: ET.Element) -> GroupItem: + group_item = GroupItem() + group_item._id = group_xml.get("id") + group_item.name = group_xml.get("name") + return group_item + + group_set_item = cls() + group_set_item.name = groupset_xml.get("name") + group_set_item.id = groupset_xml.get("id") + group_set_item.group_count = int(count) if (count := groupset_xml.get("groupCount")) else 0 + group_set_item.groups = [ + get_group(group_xml) for group_xml in groupset_xml.findall(".//t:group", namespaces=ns) + ] + + return group_set_item diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index 024350aaa..e6b50b27d 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -12,6 +12,7 @@ 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.groupsets_endpoint import GroupSets from tableauserverclient.server.endpoint.jobs_endpoint import Jobs from tableauserverclient.server.endpoint.metadata_endpoint import Metadata from tableauserverclient.server.endpoint.metrics_endpoint import Metrics @@ -43,6 +44,7 @@ "Flows", "FlowTasks", "Groups", + "GroupSets", "Jobs", "Metadata", "Metrics", diff --git a/tableauserverclient/server/endpoint/groupsets_endpoint.py b/tableauserverclient/server/endpoint/groupsets_endpoint.py new file mode 100644 index 000000000..d24cab52c --- /dev/null +++ b/tableauserverclient/server/endpoint/groupsets_endpoint.py @@ -0,0 +1,87 @@ +from typing import List, Literal, Optional, Tuple, TYPE_CHECKING, Union + +from tableauserverclient.helpers.logging import logger +from tableauserverclient.models.group_item import GroupItem +from tableauserverclient.models.groupset_item import GroupSetItem +from tableauserverclient.models.pagination_item import PaginationItem +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint +from tableauserverclient.server.request_options import RequestOptions +from tableauserverclient.server.request_factory import RequestFactory +from tableauserverclient.server.endpoint.endpoint import api + +if TYPE_CHECKING: + from tableauserverclient.server import Server + + +class GroupSets(QuerysetEndpoint[GroupSetItem]): + def __init__(self, parent_srv: "Server") -> None: + super().__init__(parent_srv) + + @property + def baseurl(self) -> str: + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/groupsets" + + @api(version="3.22") + def get( + self, + request_options: Optional[RequestOptions] = None, + result_level: Optional[Literal["members", "local"]] = None, + ) -> Tuple[List[GroupSetItem], PaginationItem]: + logger.info("Querying all group sets on site") + url = self.baseurl + if result_level: + url += f"?resultlevel={result_level}" + server_response = self.get_request(url, request_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + all_group_set_items = GroupSetItem.from_response(server_response.content, self.parent_srv.namespace) + return all_group_set_items, pagination_item + + @api(version="3.22") + def get_by_id(self, groupset_id: str) -> GroupSetItem: + logger.info(f"Querying group set (ID: {groupset_id})") + url = f"{self.baseurl}/{groupset_id}" + server_response = self.get_request(url) + all_group_set_items = GroupSetItem.from_response(server_response.content, self.parent_srv.namespace) + return all_group_set_items[0] + + @api(version="3.22") + def create(self, groupset_item: GroupSetItem) -> GroupSetItem: + logger.info(f"Creating group set (name: {groupset_item.name})") + url = self.baseurl + request = RequestFactory.GroupSet.create_request(groupset_item) + server_response = self.post_request(url, request) + created_groupset = GroupSetItem.from_response(server_response.content, self.parent_srv.namespace) + return created_groupset[0] + + @api(version="3.22") + def add_group(self, groupset_item: GroupSetItem, group: Union[GroupItem, str]) -> None: + group_id = group.id if isinstance(group, GroupItem) else group + logger.info(f"Adding group (ID: {group_id}) to group set (ID: {groupset_item.id})") + url = f"{self.baseurl}/{groupset_item.id}/groups/{group_id}" + _ = self.put_request(url) + return None + + @api(version="3.22") + def remove_group(self, groupset_item: GroupSetItem, group: Union[GroupItem, str]) -> None: + group_id = group.id if isinstance(group, GroupItem) else group + logger.info(f"Removing group (ID: {group_id}) from group set (ID: {groupset_item.id})") + url = f"{self.baseurl}/{groupset_item.id}/groups/{group_id}" + _ = self.delete_request(url) + return None + + @api(version="3.22") + def delete(self, groupset: Union[GroupSetItem, str]) -> None: + groupset_id = groupset.id if isinstance(groupset, GroupSetItem) else groupset + logger.info(f"Deleting group set (ID: {groupset_id})") + url = f"{self.baseurl}/{groupset_id}" + _ = self.delete_request(url) + return None + + @api(version="3.22") + def update(self, groupset: GroupSetItem) -> GroupSetItem: + logger.info(f"Updating group set (ID: {groupset.id})") + url = f"{self.baseurl}/{groupset.id}" + request = RequestFactory.GroupSet.update_request(groupset) + server_response = self.put_request(url, request) + updated_groupset = GroupSetItem.from_response(server_response.content, self.parent_srv.namespace) + return updated_groupset[0] diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 87438ecde..d7f01d099 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1246,6 +1246,22 @@ def update_req(self, xml_request: ET.Element, custom_view_item: CustomViewItem): updating_element.attrib["name"] = custom_view_item.name +class GroupSetRequest: + @_tsrequest_wrapped + def create_request(self, xml_request: ET.Element, group_set_item: "GroupSetItem") -> bytes: + group_set_element = ET.SubElement(xml_request, "groupSet") + if group_set_item.name is not None: + group_set_element.attrib["name"] = group_set_item.name + return ET.tostring(xml_request) + + @_tsrequest_wrapped + def update_request(self, xml_request: ET.Element, group_set_item: "GroupSetItem") -> bytes: + group_set_element = ET.SubElement(xml_request, "groupSet") + if group_set_item.name is not None: + group_set_element.attrib["name"] = group_set_item.name + return ET.tostring(xml_request) + + class RequestFactory(object): Auth = AuthRequest() Connection = Connection() @@ -1261,6 +1277,7 @@ class RequestFactory(object): Flow = FlowRequest() FlowTask = FlowTaskRequest() Group = GroupRequest() + GroupSet = GroupSetRequest() Metric = MetricRequest() Permission = PermissionRequest() Project = ProjectRequest() diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 10b1a53ad..18d67fa07 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -33,6 +33,7 @@ Metrics, Endpoint, CustomViews, + GroupSets, ) from tableauserverclient.server.exceptions import ( ServerInfoEndpointNotFoundError, @@ -99,6 +100,7 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self.flow_runs = FlowRuns(self) self.metrics = Metrics(self) self.custom_views = CustomViews(self) + self.group_sets = GroupSets(self) self._session = self._session_factory() self._http_options = dict() # must set this before making a server call diff --git a/test/assets/groupsets_create.xml b/test/assets/groupsets_create.xml new file mode 100644 index 000000000..233b0f939 --- /dev/null +++ b/test/assets/groupsets_create.xml @@ -0,0 +1,4 @@ + + + + diff --git a/test/assets/groupsets_get.xml b/test/assets/groupsets_get.xml new file mode 100644 index 000000000..ff3bec1fb --- /dev/null +++ b/test/assets/groupsets_get.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/test/assets/groupsets_get_by_id.xml b/test/assets/groupsets_get_by_id.xml new file mode 100644 index 000000000..558e4d870 --- /dev/null +++ b/test/assets/groupsets_get_by_id.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/test/assets/groupsets_update.xml b/test/assets/groupsets_update.xml new file mode 100644 index 000000000..b64fa6ea1 --- /dev/null +++ b/test/assets/groupsets_update.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/test/test_groupsets.py b/test/test_groupsets.py new file mode 100644 index 000000000..d3c9085a4 --- /dev/null +++ b/test/test_groupsets.py @@ -0,0 +1,130 @@ +from pathlib import Path +import unittest + +from defusedxml.ElementTree import fromstring +import requests_mock + +import tableauserverclient as TSC + +TEST_ASSET_DIR = Path(__file__).parent / "assets" +GROUPSET_CREATE = TEST_ASSET_DIR / "groupsets_create.xml" +GROUPSETS_GET = TEST_ASSET_DIR / "groupsets_get.xml" +GROUPSET_GET_BY_ID = TEST_ASSET_DIR / "groupsets_get_by_id.xml" +GROUPSET_UPDATE = TEST_ASSET_DIR / "groupsets_get_by_id.xml" + + +class TestGroupSets(unittest.TestCase): + def setUp(self) -> None: + 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" + + self.baseurl = self.server.group_sets.baseurl + + def test_get(self) -> None: + with requests_mock.mock() as m: + m.get(self.baseurl, text=GROUPSETS_GET.read_text()) + groupsets, pagination_item = self.server.group_sets.get() + + assert len(groupsets) == 3 + assert pagination_item.total_available == 3 + assert groupsets[0].id == "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + assert groupsets[0].name == "All Users" + assert groupsets[0].group_count == 1 + assert groupsets[0].groups[0].name == "group-one" + assert groupsets[0].groups[0].id == "gs-1" + + assert groupsets[1].id == "9a8a7b6b-5c4c-3d2d-1e0e-9a8a7b6b5b4b" + assert groupsets[1].name == "active-directory-group-import" + assert groupsets[1].group_count == 1 + assert groupsets[1].groups[0].name == "group-two" + assert groupsets[1].groups[0].id == "gs21" + + assert groupsets[2].id == "7b6b59a8-ac3c-4d1d-2e9e-0b5b4ba8a7b6" + assert groupsets[2].name == "local-group-license-on-login" + assert groupsets[2].group_count == 1 + assert groupsets[2].groups[0].name == "group-three" + assert groupsets[2].groups[0].id == "gs-3" + + def test_get_by_id(self) -> None: + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", text=GROUPSET_GET_BY_ID.read_text()) + groupset = self.server.group_sets.get_by_id("1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d") + + assert groupset.id == "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + assert groupset.name == "All Users" + assert groupset.group_count == 3 + assert len(groupset.groups) == 3 + + assert groupset.groups[0].name == "group-one" + assert groupset.groups[0].id == "gs-1" + assert groupset.groups[1].name == "group-two" + assert groupset.groups[1].id == "gs21" + assert groupset.groups[2].name == "group-three" + assert groupset.groups[2].id == "gs-3" + + def test_update(self) -> None: + id_ = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + groupset = TSC.GroupSetItem("All Users") + groupset.id = id_ + with requests_mock.mock() as m: + m.put(f"{self.baseurl}/{id_}", text=GROUPSET_UPDATE.read_text()) + groupset = self.server.group_sets.update(groupset) + + assert groupset.id == id_ + assert groupset.name == "All Users" + assert groupset.group_count == 3 + assert len(groupset.groups) == 3 + + assert groupset.groups[0].name == "group-one" + assert groupset.groups[0].id == "gs-1" + assert groupset.groups[1].name == "group-two" + assert groupset.groups[1].id == "gs21" + assert groupset.groups[2].name == "group-three" + assert groupset.groups[2].id == "gs-3" + + def test_create(self) -> None: + groupset = TSC.GroupSetItem("All Users") + with requests_mock.mock() as m: + m.post(self.baseurl, text=GROUPSET_CREATE.read_text()) + groupset = self.server.group_sets.create(groupset) + + assert groupset.id == "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + assert groupset.name == "All Users" + assert groupset.group_count == 0 + assert len(groupset.groups) == 0 + + def test_add_group(self) -> None: + groupset = TSC.GroupSetItem("All") + groupset.id = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + group = TSC.GroupItem("Example") + group._id = "ef8b19c0-43b6-11e6-af50-63f5805dbe3c" + + with requests_mock.mock() as m: + m.put(f"{self.baseurl}/{groupset.id}/groups/{group._id}") + self.server.group_sets.add_group(groupset, group) + + history = m.request_history + + assert len(history) == 1 + assert history[0].method == "PUT" + assert history[0].url == f"{self.baseurl}/{groupset.id}/groups/{group._id}" + + def test_remove_group(self) -> None: + groupset = TSC.GroupSetItem("All") + groupset.id = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + group = TSC.GroupItem("Example") + group._id = "ef8b19c0-43b6-11e6-af50-63f5805dbe3c" + + with requests_mock.mock() as m: + m.delete(f"{self.baseurl}/{groupset.id}/groups/{group._id}") + self.server.group_sets.remove_group(groupset, group) + + history = m.request_history + + assert len(history) == 1 + assert history[0].method == "DELETE" + assert history[0].url == f"{self.baseurl}/{groupset.id}/groups/{group._id}" From 6351d36bc906403041ea046d23e29027e538a7e9 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 28 Jun 2024 06:44:40 -0500 Subject: [PATCH 181/296] feat: add tag name to GroupSet --- tableauserverclient/models/groupset_item.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tableauserverclient/models/groupset_item.py b/tableauserverclient/models/groupset_item.py index 9df87ef3f..879e8b02d 100644 --- a/tableauserverclient/models/groupset_item.py +++ b/tableauserverclient/models/groupset_item.py @@ -7,6 +7,8 @@ class GroupSetItem: + tag_name: str = "groupSet" + def __init__(self, name: Optional[str] = None) -> None: self.name = name self.id: Optional[str] = None 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 182/296] 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) From 91cdd5f7584ca1f2c3bda874d98842d09e4c023b Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Sat, 27 Jul 2024 17:27:47 -0700 Subject: [PATCH 183/296] Update Contributors list Updating this list which had become quite old. Also including a simple script for updating the list periodically. TODO: Improve the script to include the user's name as well. (Will require using a Github token to avoid being rate limited.) --- CONTRIBUTORS.md | 126 ++++++++++++++++++++++++++------------------- getcontributors.py | 9 ++++ 2 files changed, 82 insertions(+), 53 deletions(-) create mode 100644 getcontributors.py diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 89b8d213c..a69cfff21 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -4,59 +4,79 @@ The following people have contributed to this project to make it possible, and w ## Contributors +* [jacalata](https://github.com/jacalata) +* [jorwoods](https://github.com/jorwoods) +* [t8y8](https://github.com/t8y8) +* [bcantoni](https://github.com/bcantoni) +* [shinchris](https://github.com/shinchris) +* [vogelsgesang](https://github.com/vogelsgesang) +* [lbrendanl](https://github.com/lbrendanl) +* [LGraber](https://github.com/LGraber) +* [gaoang2148](https://github.com/gaoang2148) +* [benlower](https://github.com/benlower) +* [liu-rebecca](https://github.com/liu-rebecca) +* [guodah](https://github.com/guodah) +* [jdomingu](https://github.com/jdomingu) +* [kykrueger](https://github.com/kykrueger) +* [jz-huang](https://github.com/jz-huang) +* [opus-42](https://github.com/opus-42) +* [markm-io](https://github.com/markm-io) +* [graysonarts](https://github.com/graysonarts) +* [d45](https://github.com/d45) +* [preguraman](https://github.com/preguraman) +* [sotnich](https://github.com/sotnich) +* [mmuttreja-tableau](https://github.com/mmuttreja-tableau) +* [dependabot[bot]](https://github.com/apps/dependabot) +* [scuml](https://github.com/scuml) +* [ovinis](https://github.com/ovinis) +* [FFMMM](https://github.com/FFMMM) +* [martinbpeters](https://github.com/martinbpeters) +* [talvalin](https://github.com/talvalin) +* [dzucker-tab](https://github.com/dzucker-tab) +* [a-torres-2](https://github.com/a-torres-2) +* [nnevalainen](https://github.com/nnevalainen) +* [mbren](https://github.com/mbren) +* [wolkiewiczk](https://github.com/wolkiewiczk) +* [jacobj10](https://github.com/jacobj10) +* [hugoboos](https://github.com/hugoboos) +* [grbritz](https://github.com/grbritz) +* [fpagliar](https://github.com/fpagliar) +* [bskim45](https://github.com/bskim45) +* [baixin137](https://github.com/baixin137) +* [jessicachen79](https://github.com/jessicachen79) +* [gconklin](https://github.com/gconklin) * [geordielad](https://github.com/geordielad) -* [Hugo Stijns](https://github.com/hugoboos) -* [kovner](https://github.com/kovner) -* [Talvalin](https://github.com/Talvalin) -* [Chris Toomey](https://github.com/cmtoomey) -* [Vathsala Achar](https://github.com/VathsalaAchar) -* [Graeme Britz](https://github.com/grbritz) -* [Russ Goldin](https://github.com/tagyoureit) -* [William Lang](https://github.com/williamlang) -* [Jim Morris](https://github.com/jimbodriven) -* [BingoDinkus](https://github.com/BingoDinkus) -* [Sergey Sotnichenko](https://github.com/sotnich) -* [Bruce Zhang](https://github.com/baixin137) -* [Bumsoo Kim](https://github.com/bskim45) +* [fossabot](https://github.com/fossabot) * [daniel1608](https://github.com/daniel1608) -* [Joshua Jacob](https://github.com/jacobj10) -* [Francisco Pagliaricci](https://github.com/fpagliar) -* [Tomasz Machalski](https://github.com/toomyem) -* [Jared Dominguez](https://github.com/jdomingu) -* [Brendan Lee](https://github.com/lbrendanl) -* [Martin Dertz](https://github.com/martydertz) -* [Christian Oliff](https://github.com/coliff) -* [Albin Antony](https://github.com/user9747) -* [prae04](https://github.com/prae04) -* [Martin Peters](https://github.com/martinbpeters) -* [Sherman K](https://github.com/shrmnk) -* [Jorge Fonseca](https://github.com/JorgeFonseca) -* [Kacper Wolkiewicz](https://github.com/wolkiewiczk) -* [Dahai Guo](https://github.com/guodah) -* [Geraldine Zanolli](https://github.com/illonage) -* [Jordan Woods](https://github.com/jorwoods) -* [Reba Magier](https://github.com/rmagier1) -* [Stephen Mitchell](https://github.com/scuml) -* [absentmoose](https://github.com/absentmoose) -* [Paul Vickers](https://github.com/paulvic) -* [Madhura Selvarajan](https://github.com/maddy-at-leisure) -* [Niklas Nevalainen](https://github.com/nnevalainen) -* [Terrence Jones](https://github.com/tjones-commits) -* [John Vandenberg](https://github.com/jayvdb) -* [Lee Boynton](https://github.com/lboynton) * [annematronic](https://github.com/annematronic) - -## Core Team - -* [Chris Shin](https://github.com/shinchris) -* [Lee Graber](https://github.com/lgraber) -* [Tyler Doyle](https://github.com/t8y8) -* [Russell Hay](https://github.com/RussTheAerialist) -* [Ben Lower](https://github.com/benlower) -* [Ang Gao](https://github.com/gaoang2148) -* [Priya Reguraman](https://github.com/preguraman) -* [Jac Fitzgerald](https://github.com/jacalata) -* [Dan Zucker](https://github.com/dzucker-tab) -* [Brian Cantoni](https://github.com/bcantoni) -* [Ovini Nanayakkara](https://github.com/ovinis) -* [Manish Muttreja](https://github.com/mmuttreja-tableau) +* [rshide](https://github.com/rshide) +* [VathsalaAchar](https://github.com/VathsalaAchar) +* [TrimPeachu](https://github.com/TrimPeachu) +* [ajbosco](https://github.com/ajbosco) +* [jimbodriven](https://github.com/jimbodriven) +* [ltiffanydev](https://github.com/ltiffanydev) +* [martydertz](https://github.com/martydertz) +* [r-richmond](https://github.com/r-richmond) +* [sfarr15](https://github.com/sfarr15) +* [tagyoureit](https://github.com/tagyoureit) +* [tjones-commits](https://github.com/tjones-commits) +* [yoshichan5](https://github.com/yoshichan5) +* [wlodi83](https://github.com/wlodi83) +* [anipmehta](https://github.com/anipmehta) +* [cmtoomey](https://github.com/cmtoomey) +* [pes-magic](https://github.com/pes-magic) +* [illonage](https://github.com/illonage) +* [jayvdb](https://github.com/jayvdb) +* [jorgeFons](https://github.com/jorgeFons) +* [Kovner](https://github.com/Kovner) +* [LarsBreddemann](https://github.com/LarsBreddemann) +* [lboynton](https://github.com/lboynton) +* [maddy-at-leisure](https://github.com/maddy-at-leisure) +* [narcolino-tableau](https://github.com/narcolino-tableau) +* [PatrickfBraz](https://github.com/PatrickfBraz) +* [paulvic](https://github.com/paulvic) +* [shrmnk](https://github.com/shrmnk) +* [TableauKyle](https://github.com/TableauKyle) +* [bossenti](https://github.com/bossenti) +* [ma7tcsp](https://github.com/ma7tcsp) +* [toomyem](https://github.com/toomyem) diff --git a/getcontributors.py b/getcontributors.py new file mode 100644 index 000000000..54ca81cb2 --- /dev/null +++ b/getcontributors.py @@ -0,0 +1,9 @@ +import json +import requests + + +logins = json.loads( + requests.get("https://api.github.com/repos/tableau/server-client-python/contributors?per_page=200").text +) +for login in logins: + print(f"* [{login["login"]}]({login["html_url"]})") From 3a27d2b928587d93b7c809b284983f0e8a2efb57 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Sat, 27 Jul 2024 17:29:33 -0700 Subject: [PATCH 184/296] Update contributing file to point to the newer Developer Guide instead --- contributing.md | 65 ++----------------------------------------------- 1 file changed, 2 insertions(+), 63 deletions(-) diff --git a/contributing.md b/contributing.md index 6404611a9..a0132919f 100644 --- a/contributing.md +++ b/contributing.md @@ -10,8 +10,6 @@ Contribution can include, but are not limited to, any of the following: * Fix an Issue/Bug * Add/Fix documentation -Contributions must follow the guidelines outlined on the [Tableau Organization](http://tableau.github.io/) page, though filing an issue or requesting a feature do not require the CLA. - ## Issues and Feature Requests To submit an issue/bug report, or to request a feature, please submit a [GitHub issue](https://github.com/tableau/server-client-python/issues) to the repo. @@ -22,65 +20,6 @@ files to assist in the repro. **Be sure to scrub the files of any potentially s For a feature request, please try to describe the scenario you are trying to accomplish that requires the feature. This will help us understand the limitations that you are running into, and provide us with a use case to know if we've satisfied your request. -### Label usage on Issues - -The core team is responsible for assigning most labels to the issue. Labels -are used for prioritizing the core team's work, and use the following -definitions for labels. - -The following labels are only to be set or changed by the core team: - -* **bug** - A bug is an unintended behavior for existing functionality. It only relates to existing functionality and the behavior that is expected with that functionality. We do not use **bug** to indicate priority. -* **enhancement** - An enhancement is a new piece of functionality and is related to the fact that new code will need to be written in order to close this issue. We do not use **enhancement** to indicate priority. -* **CLARequired** - This label is used to indicate that the contribution will require that the CLA is signed before we can accept a PR. This label should not be used on Issues -* **CLANotRequired** - This label is used to indicate that the contribution does not require a CLA to be signed. This is used for minor fixes and usually around doc fixes or correcting strings. -* **help wanted** - This label on an issue indicates it's a good choice for external contributors to take on. It usually means it's an issue that can be tackled by first time contributors. - -The following labels can be used by the issue creator or anyone in the -community to help us prioritize enhancement and bug fixes that are -causing pain from our users. The short of it is, purple tags are ones that -anyone can add to an issue: - -* **Critical** - This means that you won't be able to use the library until the issues have been resolved. If an issue is already labeled as critical, but you want to show your support for it, add a +1 comment to the issue. This helps us know what issues are really impacting our users. -* **Nice To Have** - This means that the issue doesn't block your usage of the library, but would make your life easier. Like with critical, if the issue is already tagged with this, but you want to show your support, add a +1 comment to the issue. - -## Fixes, Implementations, and Documentation - -For all other things, please submit a PR that includes the fix, documentation, or new code that you are trying to contribute. More information on -creating a PR can be found in the [Development Guide](https://tableau.github.io/server-client-python/docs/dev-guide). - -If the feature is complex or has multiple solutions that could be equally appropriate approaches, it would be helpful to file an issue to discuss the -design trade-offs of each solution before implementing, to allow us to collectively arrive at the best solution, which most likely exists in the middle -somewhere. - -## Getting Started - -```shell -python -m build -pytest -``` - -### To use your locally built version -```shell -pip install . -``` - -### Debugging Tools -See what your outgoing requests look like: https://requestbin.net/ (unaffiliated link not under our control) - - -### Before Committing - -Our CI runs include a Python lint run, so you should run this locally and fix complaints before committing as this will fail your checkin. - -```shell -# this will run the formatter without making changes -black . --check - -# this will format the directory and code for you -black . +### Making Contributions -# this will run type checking -pip install mypy -mypy tableauserverclient test samples -``` +Refer to the [Developer Guide](https://tableau.github.io/server-client-python/docs/dev-guide) which explains how to make contributions to the TSC project. From 4e578862c9590d18de1afe7aae27904309079479 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Sat, 27 Jul 2024 19:29:29 -0700 Subject: [PATCH 185/296] Add contributor pointers to readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ab6a66fae..51da7bda0 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ To see sample code that works directly with the REST API (in Java, Python, or Po For more information on installing and using TSC, see the documentation: - +To contribute, see our [Developer Guide](https://tableau.github.io/server-client-python/docs/dev-guide). A list of all our contributors to date is in [CONTRIBUTORS.md]. ## License [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python?ref=badge_large) From b6c437289390c469aa08f20b0895dfa436d86139 Mon Sep 17 00:00:00 2001 From: joelclark Date: Wed, 31 Jul 2024 08:34:17 -0500 Subject: [PATCH 186/296] Add __str__ and __repr__ to DatabaseItem --- tableauserverclient/models/database_item.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tableauserverclient/models/database_item.py b/tableauserverclient/models/database_item.py index 3d5a00a1a..dfc58e1bb 100644 --- a/tableauserverclient/models/database_item.py +++ b/tableauserverclient/models/database_item.py @@ -44,6 +44,12 @@ def __init__(self, name, description=None, content_permissions=None): self._tables = None # Not implemented yet + def __str__(self): + return "".format(self._id, self.name) + + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @property def dqws(self): if self._data_quality_warnings is None: From ac43f1a98feeb5ac7468dfa3a3e27b3676b5d3f0 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Mon, 22 Jul 2024 07:14:46 -0500 Subject: [PATCH 187/296] feat: add/remove tags endpoints for workbooks Closes #1421 --- .../server/endpoint/workbooks_endpoint.py | 39 +++++++++-- test/assets/workbook_add_tag.xml | 6 ++ test/test_workbook.py | 68 +++++++++++++++++++ 3 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 test/assets/workbook_add_tag.xml diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 30f8ce036..2614d0e4d 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -8,10 +8,10 @@ from tableauserverclient.helpers.headers import fix_filename -from .endpoint import QuerysetEndpoint, api, parameter_added_in -from .exceptions import InternalServerError, MissingRequiredFieldError -from .permissions_endpoint import _PermissionsEndpoint -from .resource_tagger import _ResourceTagger +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.filesys_helpers import ( to_filename, @@ -24,9 +24,11 @@ from tableauserverclient.server import RequestFactory from typing import ( + Iterable, List, Optional, Sequence, + Set, Tuple, TYPE_CHECKING, Union, @@ -498,3 +500,32 @@ def schedule_extract_refresh( self, schedule_id: str, item: WorkbookItem ) -> List["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, workbook=item) + + @api(version="1.0") + def add_tags(self, workbook: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + workbook = getattr(workbook, "id", workbook) + + if not isinstance(workbook, str): + raise ValueError("Workbook ID not found.") + + if isinstance(tags, str): + tag_set = set([tags]) + else: + tag_set = set(tags) + + return self._resource_tagger._add_tags(self.baseurl, workbook, tag_set) + + @api(version="1.0") + def delete_tags(self, workbook: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> None: + workbook = getattr(workbook, "id", workbook) + + if not isinstance(workbook, str): + raise ValueError("Workbook ID not found.") + + if isinstance(tags, str): + tag_set = set([tags]) + else: + tag_set = set(tags) + + for tag in tag_set: + self._resource_tagger._delete_tag(self.baseurl, workbook, tag) diff --git a/test/assets/workbook_add_tag.xml b/test/assets/workbook_add_tag.xml new file mode 100644 index 000000000..567e3f6fa --- /dev/null +++ b/test/assets/workbook_add_tag.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/test_workbook.py b/test/test_workbook.py index 950118dc0..c807043f6 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -18,6 +18,7 @@ TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") +ADD_TAG_XML = os.path.join(TEST_ASSET_DIR, "workbook_add_tag.xml") ADD_TAGS_XML = os.path.join(TEST_ASSET_DIR, "workbook_add_tags.xml") GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_by_id.xml") GET_BY_ID_XML_PERSONAL = os.path.join(TEST_ASSET_DIR, "workbook_get_by_id_personal.xml") @@ -894,3 +895,70 @@ def test_odata_connection(self) -> None: assert xml_connection is not None self.assertEqual(xml_connection.get("serverAddress"), url) + + def test_add_tags(self) -> None: + workbook = TSC.WorkbookItem("project", "test") + workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" + tags = list("abcd") + + with requests_mock.mock() as m: + m.put( + f"{self.baseurl}/{workbook.id}/tags", + status_code=200, + text=Path(ADD_TAGS_XML).read_text(), + ) + tag_result = self.server.workbooks.add_tags(workbook, tags) + + for a, b in zip(sorted(tag_result), sorted(tags)): + self.assertEqual(a, b) + + def test_add_tag(self) -> None: + workbook = TSC.WorkbookItem("project", "test") + workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" + tags = "a" + + with requests_mock.mock() as m: + m.put( + f"{self.baseurl}/{workbook.id}/tags", + status_code=200, + text=Path(ADD_TAG_XML).read_text(), + ) + tag_result = self.server.workbooks.add_tags(workbook, tags) + + for a, b in zip(sorted(tag_result), sorted(tags)): + self.assertEqual(a, b) + + def test_add_tag_id(self) -> None: + workbook = TSC.WorkbookItem("project", "test") + workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" + tags = "a" + + with requests_mock.mock() as m: + m.put( + f"{self.baseurl}/{workbook.id}/tags", + status_code=200, + text=Path(ADD_TAG_XML).read_text(), + ) + tag_result = self.server.workbooks.add_tags(workbook.id, tags) + + for a, b in zip(sorted(tag_result), sorted(tags)): + self.assertEqual(a, b) + + def test_delete_tags(self) -> None: + workbook = TSC.WorkbookItem("project", "test") + workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" + tags = list("abcd") + + matcher = re.compile(rf"{self.baseurl}\/{workbook.id}\/tags\/[abcd]") + with requests_mock.mock() as m: + m.delete( + matcher, + status_code=200, + text="", + ) + self.server.workbooks.delete_tags(workbook, tags) + history = m.request_history + + self.assertEqual(len(history), len(tags)) + urls = sorted([r.url.split("/")[-1] for r in history]) + self.assertEqual(urls, sorted(tags)) From 35672c32ae9f359610f92b2161099674ac95c144 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 1 Aug 2024 22:27:27 -0500 Subject: [PATCH 188/296] refactor: use mixin class for tagging endpoints --- .../server/endpoint/resource_tagger.py | 61 ++++++++++- .../server/endpoint/workbooks_endpoint.py | 34 +----- test/assets/workbook_add_tag.xml | 6 -- test/assets/workbook_add_tags.xml | 9 -- test/test_tagging.py | 102 ++++++++++++++++++ 5 files changed, 165 insertions(+), 47 deletions(-) delete mode 100644 test/assets/workbook_add_tag.xml delete mode 100644 test/assets/workbook_add_tags.xml create mode 100644 test/test_tagging.py diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index 8177bd733..2d70bbe5c 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -1,9 +1,11 @@ +import abc import copy +from typing import Generic, Iterable, Set, TypeVar, Union import urllib.parse -from .endpoint import Endpoint -from .exceptions import ServerResponseError -from ..exceptions import EndpointUnavailableError +from tableauserverclient.server.endpoint.endpoint import Endpoint +from tableauserverclient.server.endpoint.exceptions import ServerResponseError +from tableauserverclient.server.exceptions import EndpointUnavailableError from tableauserverclient.server import RequestFactory from tableauserverclient.models import TagItem @@ -49,3 +51,56 @@ def update_tags(self, baseurl, resource_item): resource_item.tags = self._add_tags(baseurl, resource_item.id, add_set) resource_item._initial_tags = copy.copy(resource_item.tags) logger.info("Updated tags to {0}".format(resource_item.tags)) + + +T = TypeVar("T") + + +class TaggingMixin(Generic[T]): + @abc.abstractmethod + def baseurl(self) -> str: + raise NotImplementedError("baseurl must be implemented.") + + def add_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> Set[str]: + item_id = getattr(item, "id", item) + + if not isinstance(item_id, str): + raise ValueError("ID not found.") + + if isinstance(tags, str): + tag_set = set([tags]) + else: + tag_set = set(tags) + + url = f"{self.baseurl}/{item_id}/tags" + add_req = RequestFactory.Tag.add_req(tag_set) + server_response = self.put_request(url, add_req) + return TagItem.from_response(server_response.content, self.parent_srv.namespace) + + def delete_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> None: + item_id = getattr(item, "id", item) + + if not isinstance(item_id, str): + raise ValueError("ID not found.") + + if isinstance(tags, str): + tag_set = set([tags]) + else: + tag_set = set(tags) + + for tag in tag_set: + encoded_tag_name = urllib.parse.quote(tag) + url = f"{self.baseurl}/{item_id}/tags/{encoded_tag_name}" + self.delete_request(url) + + def update_tags(self, item: T) -> None: + if item.tags == item._initial_tags: + return + + add_set = item.tags - item._initial_tags + remove_set = item._initial_tags - item.tags + self.delete_tags(item, remove_set) + if add_set: + item.tags = self.add_tags(item, add_set) + item._initial_tags = copy.copy(item.tags) + logger.info(f"Updated tags to {item.tags}") diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 2614d0e4d..53f6352f9 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -11,7 +11,7 @@ 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.server.endpoint.resource_tagger import _ResourceTagger, TaggingMixin from tableauserverclient.filesys_helpers import ( to_filename, @@ -58,7 +58,7 @@ PathOrFileW = Union[FilePath, FileObjectW] -class Workbooks(QuerysetEndpoint[WorkbookItem]): +class Workbooks(QuerysetEndpoint[WorkbookItem], TaggingMixin[WorkbookItem]): def __init__(self, parent_srv: "Server") -> None: super(Workbooks, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) @@ -501,31 +501,7 @@ def schedule_extract_refresh( ) -> List["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, workbook=item) - @api(version="1.0") - def add_tags(self, workbook: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> Set[str]: - workbook = getattr(workbook, "id", workbook) - if not isinstance(workbook, str): - raise ValueError("Workbook ID not found.") - - if isinstance(tags, str): - tag_set = set([tags]) - else: - tag_set = set(tags) - - return self._resource_tagger._add_tags(self.baseurl, workbook, tag_set) - - @api(version="1.0") - def delete_tags(self, workbook: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> None: - workbook = getattr(workbook, "id", workbook) - - if not isinstance(workbook, str): - raise ValueError("Workbook ID not found.") - - if isinstance(tags, str): - tag_set = set([tags]) - else: - tag_set = set(tags) - - for tag in tag_set: - self._resource_tagger._delete_tag(self.baseurl, workbook, tag) +Workbooks.add_tags = api(version="1.0")(Workbooks.add_tags) +Workbooks.delete_tags = api(version="1.0")(Workbooks.delete_tags) +Workbooks.update_tags = api(version="1.0")(Workbooks.update_tags) diff --git a/test/assets/workbook_add_tag.xml b/test/assets/workbook_add_tag.xml deleted file mode 100644 index 567e3f6fa..000000000 --- a/test/assets/workbook_add_tag.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/test/assets/workbook_add_tags.xml b/test/assets/workbook_add_tags.xml deleted file mode 100644 index 8af59ecc9..000000000 --- a/test/assets/workbook_add_tags.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/test/test_tagging.py b/test/test_tagging.py new file mode 100644 index 000000000..b05d4b414 --- /dev/null +++ b/test/test_tagging.py @@ -0,0 +1,102 @@ +import re +from typing import Iterable +from xml.etree import ElementTree as ET + +import pytest +import requests_mock +import tableauserverclient as TSC + + +@pytest.fixture +def get_server() -> TSC.Server: + server = TSC.Server("http://test", False) + + # Fake sign in + server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + server.version = "3.28" + return server + + +def xml_response_factory(tags: Iterable[str]) -> str: + root = ET.Element("tsResponse") + tags_element = ET.SubElement(root, "tags") + for tag in tags: + tag_element = ET.SubElement(tags_element, "tag") + tag_element.attrib["label"] = tag + root.attrib["xmlns"] = "http://tableau.com/api" + return ET.tostring(root, encoding="utf-8").decode("utf-8") + + +def make_workbook() -> TSC.WorkbookItem: + workbook = TSC.WorkbookItem("project", "test") + workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" + return workbook + + +@pytest.mark.parametrize( + "endpoint_type, item", + [ + ("workbooks", make_workbook()), + ], +) +@pytest.mark.parametrize( + "tags", + [ + "a", + ["a", "b"], + ], +) +def test_add_tags(get_server, endpoint_type, item, tags) -> None: + add_tags_xml = xml_response_factory(tags) + endpoint = getattr(get_server, endpoint_type) + id_ = getattr(item, "id", item) + + with requests_mock.mock() as m: + m.put( + f"{endpoint.baseurl}/{id_}/tags", + status_code=200, + text=add_tags_xml, + ) + tag_result = endpoint.add_tags(item, tags) + + if isinstance(tags, str): + tags = [tags] + assert set(tag_result) == set(tags) + + +@pytest.mark.parametrize( + "endpoint_type, item", + [ + ("workbooks", make_workbook()), + ], +) +@pytest.mark.parametrize( + "tags", + [ + "a", + ["a", "b"], + ], +) +def test_delete_tags(get_server, endpoint_type, item, tags) -> None: + add_tags_xml = xml_response_factory(tags) + endpoint = getattr(get_server, endpoint_type) + id_ = getattr(item, "id", item) + + if isinstance(tags, str): + tags = [tags] + tag_paths = "|".join(tags) + tag_paths = f"({tag_paths})" + matcher = re.compile(rf"{endpoint.baseurl}\/{id_}\/tags\/{tag_paths}") + with requests_mock.mock() as m: + m.delete( + matcher, + status_code=200, + text=add_tags_xml, + ) + endpoint.delete_tags(item, tags) + history = m.request_history + + assert len(history) == len(tags) + urls = sorted([r.url.split("/")[-1] for r in history]) + assert set(urls) == set(tags) From 8fb818957c4495cb1759dacb40f5f02cc55d9922 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 1 Aug 2024 22:50:53 -0500 Subject: [PATCH 189/296] style: mypy and black --- .../server/endpoint/resource_tagger.py | 30 ++++---- .../server/endpoint/workbooks_endpoint.py | 8 +-- test/assets/workbook_add_tags.xml | 9 +++ test/test_workbook.py | 68 ------------------- 4 files changed, 28 insertions(+), 87 deletions(-) create mode 100644 test/assets/workbook_add_tags.xml diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index 2d70bbe5c..36bfee29c 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -1,6 +1,6 @@ import abc import copy -from typing import Generic, Iterable, Set, TypeVar, Union +from typing import Generic, Iterable, Optional, Protocol, Set, TypeVar, Union, runtime_checkable import urllib.parse from tableauserverclient.server.endpoint.endpoint import Endpoint @@ -53,15 +53,15 @@ def update_tags(self, baseurl, resource_item): logger.info("Updated tags to {0}".format(resource_item.tags)) -T = TypeVar("T") +@runtime_checkable +class Taggable(Protocol): + _initial_tags: Set[str] + id: Optional[str] = None + tags: Set[str] -class TaggingMixin(Generic[T]): - @abc.abstractmethod - def baseurl(self) -> str: - raise NotImplementedError("baseurl must be implemented.") - - def add_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> Set[str]: +class TaggingMixin: + def add_tags(self, item: Union[Taggable, str], tags: Union[Iterable[str], str]) -> Set[str]: item_id = getattr(item, "id", item) if not isinstance(item_id, str): @@ -72,12 +72,12 @@ def add_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> Set[ else: tag_set = set(tags) - url = f"{self.baseurl}/{item_id}/tags" + url = f"{self.baseurl}/{item_id}/tags" # type: ignore add_req = RequestFactory.Tag.add_req(tag_set) - server_response = self.put_request(url, add_req) - return TagItem.from_response(server_response.content, self.parent_srv.namespace) + server_response = self.put_request(url, add_req) # type: ignore + return TagItem.from_response(server_response.content, self.parent_srv.namespace) # type: ignore - def delete_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> None: + def delete_tags(self, item: Union[Taggable, str], tags: Union[Iterable[str], str]) -> None: item_id = getattr(item, "id", item) if not isinstance(item_id, str): @@ -90,10 +90,10 @@ def delete_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> N for tag in tag_set: encoded_tag_name = urllib.parse.quote(tag) - url = f"{self.baseurl}/{item_id}/tags/{encoded_tag_name}" - self.delete_request(url) + url = f"{self.baseurl}/{item_id}/tags/{encoded_tag_name}" # type: ignore + self.delete_request(url) # type: ignore - def update_tags(self, item: T) -> None: + def update_tags(self, item: Taggable) -> None: if item.tags == item._initial_tags: return diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 53f6352f9..3051ca6b6 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -58,7 +58,7 @@ PathOrFileW = Union[FilePath, FileObjectW] -class Workbooks(QuerysetEndpoint[WorkbookItem], TaggingMixin[WorkbookItem]): +class Workbooks(QuerysetEndpoint[WorkbookItem], TaggingMixin): def __init__(self, parent_srv: "Server") -> None: super(Workbooks, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) @@ -502,6 +502,6 @@ def schedule_extract_refresh( return self.parent_srv.schedules.add_to_schedule(schedule_id, workbook=item) -Workbooks.add_tags = api(version="1.0")(Workbooks.add_tags) -Workbooks.delete_tags = api(version="1.0")(Workbooks.delete_tags) -Workbooks.update_tags = api(version="1.0")(Workbooks.update_tags) +Workbooks.add_tags = api(version="1.0")(Workbooks.add_tags) # type: ignore +Workbooks.delete_tags = api(version="1.0")(Workbooks.delete_tags) # type: ignore +Workbooks.update_tags = api(version="1.0")(Workbooks.update_tags) # type: ignore diff --git a/test/assets/workbook_add_tags.xml b/test/assets/workbook_add_tags.xml new file mode 100644 index 000000000..8af59ecc9 --- /dev/null +++ b/test/assets/workbook_add_tags.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/test/test_workbook.py b/test/test_workbook.py index c807043f6..950118dc0 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -18,7 +18,6 @@ TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") -ADD_TAG_XML = os.path.join(TEST_ASSET_DIR, "workbook_add_tag.xml") ADD_TAGS_XML = os.path.join(TEST_ASSET_DIR, "workbook_add_tags.xml") GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_by_id.xml") GET_BY_ID_XML_PERSONAL = os.path.join(TEST_ASSET_DIR, "workbook_get_by_id_personal.xml") @@ -895,70 +894,3 @@ def test_odata_connection(self) -> None: assert xml_connection is not None self.assertEqual(xml_connection.get("serverAddress"), url) - - def test_add_tags(self) -> None: - workbook = TSC.WorkbookItem("project", "test") - workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" - tags = list("abcd") - - with requests_mock.mock() as m: - m.put( - f"{self.baseurl}/{workbook.id}/tags", - status_code=200, - text=Path(ADD_TAGS_XML).read_text(), - ) - tag_result = self.server.workbooks.add_tags(workbook, tags) - - for a, b in zip(sorted(tag_result), sorted(tags)): - self.assertEqual(a, b) - - def test_add_tag(self) -> None: - workbook = TSC.WorkbookItem("project", "test") - workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" - tags = "a" - - with requests_mock.mock() as m: - m.put( - f"{self.baseurl}/{workbook.id}/tags", - status_code=200, - text=Path(ADD_TAG_XML).read_text(), - ) - tag_result = self.server.workbooks.add_tags(workbook, tags) - - for a, b in zip(sorted(tag_result), sorted(tags)): - self.assertEqual(a, b) - - def test_add_tag_id(self) -> None: - workbook = TSC.WorkbookItem("project", "test") - workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" - tags = "a" - - with requests_mock.mock() as m: - m.put( - f"{self.baseurl}/{workbook.id}/tags", - status_code=200, - text=Path(ADD_TAG_XML).read_text(), - ) - tag_result = self.server.workbooks.add_tags(workbook.id, tags) - - for a, b in zip(sorted(tag_result), sorted(tags)): - self.assertEqual(a, b) - - def test_delete_tags(self) -> None: - workbook = TSC.WorkbookItem("project", "test") - workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" - tags = list("abcd") - - matcher = re.compile(rf"{self.baseurl}\/{workbook.id}\/tags\/[abcd]") - with requests_mock.mock() as m: - m.delete( - matcher, - status_code=200, - text="", - ) - self.server.workbooks.delete_tags(workbook, tags) - history = m.request_history - - self.assertEqual(len(history), len(tags)) - urls = sorted([r.url.split("/")[-1] for r in history]) - self.assertEqual(urls, sorted(tags)) From 8f1ff4c256b2c0ff82abf7f5f7b24d8d015961fb Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 1 Aug 2024 22:54:57 -0500 Subject: [PATCH 190/296] feat: add tagging support to views --- .../server/endpoint/views_endpoint.py | 16 ++++++++++------ test/test_tagging.py | 7 +++++++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index f98eb1cd7..293a12e63 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -1,10 +1,10 @@ import logging from contextlib import closing -from .endpoint import QuerysetEndpoint, api -from .exceptions import MissingRequiredFieldError -from .permissions_endpoint import _PermissionsEndpoint -from .resource_tagger import _ResourceTagger +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api +from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError +from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint +from tableauserverclient.server.endpoint.resource_tagger import _ResourceTagger, TaggingMixin from tableauserverclient.models import ViewItem, PaginationItem from tableauserverclient.helpers.logging import logger @@ -21,7 +21,7 @@ ) -class Views(QuerysetEndpoint[ViewItem]): +class Views(QuerysetEndpoint[ViewItem], TaggingMixin): def __init__(self, parent_srv): super(Views, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) @@ -152,7 +152,7 @@ def _get_view_excel(self, view_item: ViewItem, req_options: Optional["ExcelReque yield from server_response.iter_content(1024) @api(version="3.2") - def populate_permissions(self, item: ViewItem) -> None: + def populate_permissions(self, item: ViewItem) -> None: self._permissions.populate(item) @api(version="3.2") @@ -173,3 +173,7 @@ def update(self, view_item: ViewItem) -> ViewItem: # Returning view item to stay consistent with datasource/view update functions return view_item + +Views.add_tags = api(version="1.0")(Views.add_tags) # type: ignore +Views.delete_tags = api(version="1.0")(Views.delete_tags) # type: ignore +Views.update_tags = api(version="1.0")(Views.update_tags) # type: ignore diff --git a/test/test_tagging.py b/test/test_tagging.py index b05d4b414..8c8defe83 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -33,11 +33,17 @@ def make_workbook() -> TSC.WorkbookItem: workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" return workbook +def make_view() -> TSC.ViewItem: + view = TSC.ViewItem() + view._id = "06b944d2-959d-4604-9305-12323c95e70e" + return view + @pytest.mark.parametrize( "endpoint_type, item", [ ("workbooks", make_workbook()), + ("views", make_view()), ], ) @pytest.mark.parametrize( @@ -69,6 +75,7 @@ def test_add_tags(get_server, endpoint_type, item, tags) -> None: "endpoint_type, item", [ ("workbooks", make_workbook()), + ("views", make_view()), ], ) @pytest.mark.parametrize( From ee771d80ab18edd14a09d598942fc1bcc36d5032 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 1 Aug 2024 22:57:16 -0500 Subject: [PATCH 191/296] style: black --- tableauserverclient/server/endpoint/views_endpoint.py | 3 ++- test/test_tagging.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 293a12e63..a95f9bf60 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -152,7 +152,7 @@ def _get_view_excel(self, view_item: ViewItem, req_options: Optional["ExcelReque yield from server_response.iter_content(1024) @api(version="3.2") - def populate_permissions(self, item: ViewItem) -> None: + def populate_permissions(self, item: ViewItem) -> None: self._permissions.populate(item) @api(version="3.2") @@ -174,6 +174,7 @@ def update(self, view_item: ViewItem) -> ViewItem: # Returning view item to stay consistent with datasource/view update functions return view_item + Views.add_tags = api(version="1.0")(Views.add_tags) # type: ignore Views.delete_tags = api(version="1.0")(Views.delete_tags) # type: ignore Views.update_tags = api(version="1.0")(Views.update_tags) # type: ignore diff --git a/test/test_tagging.py b/test/test_tagging.py index 8c8defe83..966ef04b7 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -33,6 +33,7 @@ def make_workbook() -> TSC.WorkbookItem: workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" return workbook + def make_view() -> TSC.ViewItem: view = TSC.ViewItem() view._id = "06b944d2-959d-4604-9305-12323c95e70e" From a625382bfa86398d00dd5e144132aeabc822afb2 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 1 Aug 2024 22:59:46 -0500 Subject: [PATCH 192/296] feat: add tagging to datasource endpoint --- .../server/endpoint/datasources_endpoint.py | 9 +++++++-- test/test_tagging.py | 8 ++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 316f078a2..d15c5ee20 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -19,7 +19,7 @@ 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.server.endpoint.resource_tagger import _ResourceTagger, TaggingMixin from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, FILESIZE_LIMIT_MB, BYTES_PER_MB, CHUNK_SIZE_MB from tableauserverclient.filesys_helpers import ( @@ -54,7 +54,7 @@ PathOrFileW = Union[FilePath, FileObjectW] -class Datasources(QuerysetEndpoint[DatasourceItem]): +class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin): def __init__(self, parent_srv: "Server") -> None: super(Datasources, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) @@ -459,3 +459,8 @@ def schedule_extract_refresh( self, schedule_id: str, item: DatasourceItem ) -> List["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, datasource=item) + + +Datasources.add_tags = api(version="1.0")(Datasources.add_tags) # type: ignore +Datasources.delete_tags = api(version="1.0")(Datasources.delete_tags) # type: ignore +Datasources.update_tags = api(version="1.0")(Datasources.update_tags) # type: ignore diff --git a/test/test_tagging.py b/test/test_tagging.py index 966ef04b7..64ad7013c 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -40,11 +40,18 @@ def make_view() -> TSC.ViewItem: return view +def make_datasource() -> TSC.DatasourceItem: + datasource = TSC.DatasourceItem("project", "test") + datasource._id = "06b944d2-959d-4604-9305-12323c95e70e" + return datasource + + @pytest.mark.parametrize( "endpoint_type, item", [ ("workbooks", make_workbook()), ("views", make_view()), + ("datasources", make_datasource()), ], ) @pytest.mark.parametrize( @@ -77,6 +84,7 @@ def test_add_tags(get_server, endpoint_type, item, tags) -> None: [ ("workbooks", make_workbook()), ("views", make_view()), + ("datasources", make_datasource()), ], ) @pytest.mark.parametrize( From 3209f3ad4febbf50085c0a860a93bcc5d5f8e7d6 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 2 Aug 2024 06:57:49 -0500 Subject: [PATCH 193/296] test: add/delete tags with just object id --- test/test_tagging.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/test_tagging.py b/test/test_tagging.py index 64ad7013c..c9f98fce8 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -50,8 +50,11 @@ def make_datasource() -> TSC.DatasourceItem: "endpoint_type, item", [ ("workbooks", make_workbook()), + ("workbooks", "some_id"), ("views", make_view()), + ("views", "some_id"), ("datasources", make_datasource()), + ("datasources", "some_id"), ], ) @pytest.mark.parametrize( @@ -83,8 +86,11 @@ def test_add_tags(get_server, endpoint_type, item, tags) -> None: "endpoint_type, item", [ ("workbooks", make_workbook()), + ("workbooks", "some_id"), ("views", make_view()), + ("views", "some_id"), ("datasources", make_datasource()), + ("datasources", "some_id"), ], ) @pytest.mark.parametrize( From 1b458163810263b9f3739e9442b592e71c46bf18 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 2 Aug 2024 07:06:06 -0500 Subject: [PATCH 194/296] feat: tag tables --- .../server/endpoint/tables_endpoint.py | 18 ++++++++++++------ test/test_tagging.py | 10 ++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index b4c5181e9..d3b53897f 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -1,17 +1,18 @@ import logging -from .dqw_endpoint import _DataQualityWarningEndpoint -from .endpoint import api, Endpoint -from .exceptions import MissingRequiredFieldError -from .permissions_endpoint import _PermissionsEndpoint +from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint +from tableauserverclient.server.endpoint.endpoint import api, Endpoint +from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError +from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint +from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin from tableauserverclient.server import RequestFactory from tableauserverclient.models import TableItem, ColumnItem, PaginationItem -from ..pager import Pager +from tableauserverclient.server.pager import Pager from tableauserverclient.helpers.logging import logger -class Tables(Endpoint): +class Tables(Endpoint, TaggingMixin): def __init__(self, parent_srv): super(Tables, self).__init__(parent_srv) @@ -124,3 +125,8 @@ def add_dqw(self, item, warning): @api(version="3.5") def delete_dqw(self, item): self._data_quality_warnings.clear(item) + + +Tables.add_tags = api(version="3.9")(Tables.add_tags) # type: ignore +Tables.delete_tags = api(version="3.9")(Tables.delete_tags) # type: ignore +Tables.update_tags = api(version="3.9")(Tables.update_tags) # type: ignore diff --git a/test/test_tagging.py b/test/test_tagging.py index c9f98fce8..c91ddb93c 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -46,6 +46,12 @@ def make_datasource() -> TSC.DatasourceItem: return datasource +def make_table() -> TSC.TableItem: + table = TSC.TableItem("project", "test") + table._id = "06b944d2-959d-4604-9305-12323c95e70e" + return table + + @pytest.mark.parametrize( "endpoint_type, item", [ @@ -55,6 +61,8 @@ def make_datasource() -> TSC.DatasourceItem: ("views", "some_id"), ("datasources", make_datasource()), ("datasources", "some_id"), + ("tables", make_table()), + ("tables", "some_id"), ], ) @pytest.mark.parametrize( @@ -91,6 +99,8 @@ def test_add_tags(get_server, endpoint_type, item, tags) -> None: ("views", "some_id"), ("datasources", make_datasource()), ("datasources", "some_id"), + ("tables", make_table()), + ("tables", "some_id"), ], ) @pytest.mark.parametrize( From 5dae8bb5f8075c851b1853896c9dce6c2c6104d6 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 2 Aug 2024 07:12:03 -0500 Subject: [PATCH 195/296] feat: tag databases --- .../server/endpoint/databases_endpoint.py | 18 ++++++++++++------ test/test_tagging.py | 10 ++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index 849072a17..ecdb3f334 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -1,17 +1,18 @@ import logging -from .default_permissions_endpoint import _DefaultPermissionsEndpoint -from .dqw_endpoint import _DataQualityWarningEndpoint -from .endpoint import api, Endpoint -from .exceptions import MissingRequiredFieldError -from .permissions_endpoint import _PermissionsEndpoint +from tableauserverclient.server.endpoint.default_permissions_endpoint import _DefaultPermissionsEndpoint +from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint +from tableauserverclient.server.endpoint.endpoint import api, Endpoint +from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError +from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint +from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin from tableauserverclient.server import RequestFactory from tableauserverclient.models import DatabaseItem, TableItem, PaginationItem, Resource from tableauserverclient.helpers.logging import logger -class Databases(Endpoint): +class Databases(Endpoint, TaggingMixin): def __init__(self, parent_srv): super(Databases, self).__init__(parent_srv) @@ -123,3 +124,8 @@ def add_dqw(self, item, warning): @api(version="3.5") def delete_dqw(self, item): self._data_quality_warnings.clear(item) + + +Databases.add_tags = api(version="3.9")(Databases.add_tags) # type: ignore +Databases.delete_tags = api(version="3.9")(Databases.delete_tags) # type: ignore +Databases.update_tags = api(version="3.9")(Databases.update_tags) # type: ignore diff --git a/test/test_tagging.py b/test/test_tagging.py index c91ddb93c..84f3da976 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -52,6 +52,12 @@ def make_table() -> TSC.TableItem: return table +def make_database() -> TSC.DatabaseItem: + database = TSC.DatabaseItem("project", "test") + database._id = "06b944d2-959d-4604-9305-12323c95e70e" + return database + + @pytest.mark.parametrize( "endpoint_type, item", [ @@ -63,6 +69,8 @@ def make_table() -> TSC.TableItem: ("datasources", "some_id"), ("tables", make_table()), ("tables", "some_id"), + ("databases", make_database()), + ("databases", "some_id"), ], ) @pytest.mark.parametrize( @@ -101,6 +109,8 @@ def test_add_tags(get_server, endpoint_type, item, tags) -> None: ("datasources", "some_id"), ("tables", make_table()), ("tables", "some_id"), + ("databases", make_database()), + ("databases", "some_id"), ], ) @pytest.mark.parametrize( From 56301e9667c4535c870b0bf2b4d7a5be6c5a6ca0 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 2 Aug 2024 07:16:46 -0500 Subject: [PATCH 196/296] chore: unused imports --- tableauserverclient/server/endpoint/resource_tagger.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index 36bfee29c..0e8ddb7ac 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -1,6 +1,5 @@ -import abc import copy -from typing import Generic, Iterable, Optional, Protocol, Set, TypeVar, Union, runtime_checkable +from typing import Iterable, Optional, Protocol, Set, Union, runtime_checkable import urllib.parse from tableauserverclient.server.endpoint.endpoint import Endpoint From b7e6d13592d9a0e0852032ca3e6f0ec133b1f5a5 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 2 Aug 2024 07:35:20 -0500 Subject: [PATCH 197/296] fix: clean up delete_tags test comparison --- test/test_tagging.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/test_tagging.py b/test/test_tagging.py index 84f3da976..5687919b0 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -140,5 +140,5 @@ def test_delete_tags(get_server, endpoint_type, item, tags) -> None: history = m.request_history assert len(history) == len(tags) - urls = sorted([r.url.split("/")[-1] for r in history]) - assert set(urls) == set(tags) + urls = {r.url.split("/")[-1] for r in history} + assert urls == set(tags) From 9f6e151fd4d142e9a577e12bbe5b5cfd8b2f14ac Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 2 Aug 2024 21:49:04 -0500 Subject: [PATCH 198/296] feat: batch tag create and delete --- .../server/endpoint/__init__.py | 2 + .../server/endpoint/resource_tagger.py | 50 +++++++++++++- tableauserverclient/server/request_factory.py | 21 +++++- tableauserverclient/server/server.py | 2 + test/test_tagging.py | 67 ++++++++++++++++--- 5 files changed, 131 insertions(+), 11 deletions(-) diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index e6b50b27d..7b89339bc 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -22,6 +22,7 @@ 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.resource_tagger import Tags from tableauserverclient.server.endpoint.tasks_endpoint import Tasks from tableauserverclient.server.endpoint.users_endpoint import Users from tableauserverclient.server.endpoint.views_endpoint import Views @@ -55,6 +56,7 @@ "Sites", "Subscriptions", "Tables", + "Tags", "Tasks", "Users", "Views", diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index 0e8ddb7ac..b5d97721e 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -1,8 +1,8 @@ import copy -from typing import Iterable, Optional, Protocol, Set, Union, runtime_checkable +from typing import Iterable, Optional, Protocol, Set, Union, TYPE_CHECKING, runtime_checkable import urllib.parse -from tableauserverclient.server.endpoint.endpoint import Endpoint +from tableauserverclient.server.endpoint.endpoint import Endpoint, api from tableauserverclient.server.endpoint.exceptions import ServerResponseError from tableauserverclient.server.exceptions import EndpointUnavailableError from tableauserverclient.server import RequestFactory @@ -10,6 +10,15 @@ from tableauserverclient.helpers.logging import logger +if TYPE_CHECKING: + from tableauserverclient.models.column_item import ColumnItem + from tableauserverclient.models.database_item import DatabaseItem + from tableauserverclient.models.datasource_item import DatasourceItem + from tableauserverclient.models.flow_item import FlowItem + from tableauserverclient.models.table_item import TableItem + from tableauserverclient.models.workbook_item import WorkbookItem + from tableauserverclient.server.server import Server + class _ResourceTagger(Endpoint): # Add new tags to resource @@ -103,3 +112,40 @@ def update_tags(self, item: Taggable) -> None: item.tags = self.add_tags(item, add_set) item._initial_tags = copy.copy(item.tags) logger.info(f"Updated tags to {item.tags}") + + +content = Iterable[Union["ColumnItem", "DatabaseItem", "DatasourceItem", "FlowItem", "TableItem", "WorkbookItem"]] + + +class Tags(Endpoint): + def __init__(self, parent_srv: "Server"): + super().__init__(parent_srv) + + @property + def baseurl(self): + return f"{self.parent_srv.baseurl}/tags" + + @api(version="3.9") + def batch_add(self, tags: Union[Iterable[str], str], content: content) -> Set[str]: + if isinstance(tags, str): + tag_set = set([tags]) + else: + tag_set = set(tags) + + url = f"{self.baseurl}:batchCreate" + batch_create_req = RequestFactory.Tag.batch_create(tag_set, content) + server_response = self.put_request(url, batch_create_req) + return TagItem.from_response(server_response.content, self.parent_srv.namespace) + + @api(version="3.9") + def batch_delete(self, tags: Union[Iterable[str], str], content: content) -> Set[str]: + if isinstance(tags, str): + tag_set = set([tags]) + else: + tag_set = set(tags) + + url = f"{self.baseurl}:batchDelete" + # The batch delete XML is the same as the batch create XML. + batch_delete_req = RequestFactory.Tag.batch_create(tag_set, content) + server_response = self.put_request(url, batch_delete_req) + return TagItem.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index e965411cf..a83234390 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1,5 +1,5 @@ import xml.etree.ElementTree as ET -from typing import Any, Dict, Iterable, List, Optional, Tuple, TYPE_CHECKING, Union +from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Union, TYPE_CHECKING from requests.packages.urllib3.fields import RequestField from requests.packages.urllib3.filepost import encode_multipart_formdata @@ -861,6 +861,9 @@ def update_req(self, table_item): return ET.tostring(xml_request) +content_types = Iterable[Union["ColumnItem", "DatabaseItem", "DatasourceItem", "FlowItem", "TableItem", "WorkbookItem"]] + + class TagRequest(object): def add_req(self, tag_set): xml_request = ET.Element("tsRequest") @@ -870,6 +873,22 @@ def add_req(self, tag_set): tag_element.attrib["label"] = tag return ET.tostring(xml_request) + @_tsrequest_wrapped + def batch_create(self, element: ET.Element, tags: Set[str], content: content_types) -> bytes: + tag_batch = ET.SubElement(element, "tagBatch") + tags_element = ET.SubElement(tag_batch, "tags") + for tag in tags: + tag_element = ET.SubElement(tags_element, "tag") + tag_element.attrib["label"] = tag + contents_element = ET.SubElement(tag_batch, "contents") + for item in content: + content_element = ET.SubElement(contents_element, "content") + if item.id is None: + raise ValueError(f"Item {item} must have an ID to be tagged.") + content_element.attrib["id"] = item.id + + return ET.tostring(element) + class UserRequest(object): def update_req(self, user_item: UserItem, password: Optional[str]) -> bytes: diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 18d67fa07..1de865ba8 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -34,6 +34,7 @@ Endpoint, CustomViews, GroupSets, + Tags, ) from tableauserverclient.server.exceptions import ( ServerInfoEndpointNotFoundError, @@ -101,6 +102,7 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self.metrics = Metrics(self) self.custom_views = CustomViews(self) self.group_sets = GroupSets(self) + self.tags = Tags(self) self._session = self._session_factory() self._http_options = dict() # must set this before making a server call diff --git a/test/test_tagging.py b/test/test_tagging.py index 5687919b0..82936893c 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -1,10 +1,12 @@ import re from typing import Iterable +import uuid from xml.etree import ElementTree as ET import pytest import requests_mock import tableauserverclient as TSC +from tableauserverclient.server.endpoint.resource_tagger import content @pytest.fixture @@ -18,7 +20,7 @@ def get_server() -> TSC.Server: return server -def xml_response_factory(tags: Iterable[str]) -> str: +def add_tag_xml_response_factory(tags: Iterable[str]) -> str: root = ET.Element("tsResponse") tags_element = ET.SubElement(root, "tags") for tag in tags: @@ -28,33 +30,50 @@ def xml_response_factory(tags: Iterable[str]) -> str: return ET.tostring(root, encoding="utf-8").decode("utf-8") +def batch_add_tags_xml_response_factory(tags, content): + root = ET.Element("tsResponse") + tag_batch = ET.SubElement(root, "tagBatch") + tags_element = ET.SubElement(tag_batch, "tags") + for tag in tags: + tag_element = ET.SubElement(tags_element, "tag") + tag_element.attrib["label"] = tag + contents_element = ET.SubElement(tag_batch, "contents") + for item in content: + content_elem = ET.SubElement(contents_element, "content") + content_elem.attrib["id"] = item.id or "some_id" + t = item.__class__.__name__.replace("Item", "") or "" + content_elem.attrib["contentType"] = t + root.attrib["xmlns"] = "http://tableau.com/api" + return ET.tostring(root, encoding="utf-8").decode("utf-8") + + def make_workbook() -> TSC.WorkbookItem: workbook = TSC.WorkbookItem("project", "test") - workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" + workbook._id = str(uuid.uuid4()) return workbook def make_view() -> TSC.ViewItem: view = TSC.ViewItem() - view._id = "06b944d2-959d-4604-9305-12323c95e70e" + view._id = str(uuid.uuid4()) return view def make_datasource() -> TSC.DatasourceItem: datasource = TSC.DatasourceItem("project", "test") - datasource._id = "06b944d2-959d-4604-9305-12323c95e70e" + datasource._id = str(uuid.uuid4()) return datasource def make_table() -> TSC.TableItem: table = TSC.TableItem("project", "test") - table._id = "06b944d2-959d-4604-9305-12323c95e70e" + table._id = str(uuid.uuid4()) return table def make_database() -> TSC.DatabaseItem: database = TSC.DatabaseItem("project", "test") - database._id = "06b944d2-959d-4604-9305-12323c95e70e" + database._id = str(uuid.uuid4()) return database @@ -81,7 +100,7 @@ def make_database() -> TSC.DatabaseItem: ], ) def test_add_tags(get_server, endpoint_type, item, tags) -> None: - add_tags_xml = xml_response_factory(tags) + add_tags_xml = add_tag_xml_response_factory(tags) endpoint = getattr(get_server, endpoint_type) id_ = getattr(item, "id", item) @@ -121,7 +140,7 @@ def test_add_tags(get_server, endpoint_type, item, tags) -> None: ], ) def test_delete_tags(get_server, endpoint_type, item, tags) -> None: - add_tags_xml = xml_response_factory(tags) + add_tags_xml = add_tag_xml_response_factory(tags) endpoint = getattr(get_server, endpoint_type) id_ = getattr(item, "id", item) @@ -142,3 +161,35 @@ def test_delete_tags(get_server, endpoint_type, item, tags) -> None: assert len(history) == len(tags) urls = {r.url.split("/")[-1] for r in history} assert urls == set(tags) + + +def test_tags_batch_add(get_server) -> None: + server = get_server + content = [make_workbook(), make_view(), make_datasource(), make_table(), make_database()] + tags = ["a", "b"] + add_tags_xml = batch_add_tags_xml_response_factory(tags, content) + with requests_mock.mock() as m: + m.put( + f"{server.tags.baseurl}:batchCreate", + status_code=200, + text=add_tags_xml, + ) + tag_result = server.tags.batch_add(tags, content) + + assert set(tag_result) == set(tags) + + +def test_tags_batch_delete(get_server) -> None: + server = get_server + content = [make_workbook(), make_view(), make_datasource(), make_table(), make_database()] + tags = ["a", "b"] + add_tags_xml = batch_add_tags_xml_response_factory(tags, content) + with requests_mock.mock() as m: + m.put( + f"{server.tags.baseurl}:batchDelete", + status_code=200, + text=add_tags_xml, + ) + tag_result = server.tags.batch_delete(tags, content) + + assert set(tag_result) == set(tags) From 88b124f921596cd2043b74ce4615e3955623dd59 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 2 Aug 2024 22:01:15 -0500 Subject: [PATCH 199/296] chore: cleanup typing for TaggingMixin --- .../server/endpoint/resource_tagger.py | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index b5d97721e..8796fdd10 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -1,3 +1,4 @@ +import abc import copy from typing import Iterable, Optional, Protocol, Set, Union, TYPE_CHECKING, runtime_checkable import urllib.parse @@ -68,7 +69,26 @@ class Taggable(Protocol): tags: Set[str] -class TaggingMixin: +class Response(Protocol): + content: bytes + + +class TaggingMixin(abc.ABC): + parent_srv: "Server" + + @property + @abc.abstractmethod + def baseurl(self) -> str: + pass + + @abc.abstractmethod + def put_request(self, url, request) -> Response: + pass + + @abc.abstractmethod + def delete_request(self, url) -> None: + pass + def add_tags(self, item: Union[Taggable, str], tags: Union[Iterable[str], str]) -> Set[str]: item_id = getattr(item, "id", item) @@ -80,10 +100,10 @@ def add_tags(self, item: Union[Taggable, str], tags: Union[Iterable[str], str]) else: tag_set = set(tags) - url = f"{self.baseurl}/{item_id}/tags" # type: ignore + url = f"{self.baseurl}/{item_id}/tags" add_req = RequestFactory.Tag.add_req(tag_set) - server_response = self.put_request(url, add_req) # type: ignore - return TagItem.from_response(server_response.content, self.parent_srv.namespace) # type: ignore + server_response = self.put_request(url, add_req) + return TagItem.from_response(server_response.content, self.parent_srv.namespace) def delete_tags(self, item: Union[Taggable, str], tags: Union[Iterable[str], str]) -> None: item_id = getattr(item, "id", item) @@ -98,8 +118,8 @@ def delete_tags(self, item: Union[Taggable, str], tags: Union[Iterable[str], str for tag in tag_set: encoded_tag_name = urllib.parse.quote(tag) - url = f"{self.baseurl}/{item_id}/tags/{encoded_tag_name}" # type: ignore - self.delete_request(url) # type: ignore + url = f"{self.baseurl}/{item_id}/tags/{encoded_tag_name}" + self.delete_request(url) def update_tags(self, item: Taggable) -> None: if item.tags == item._initial_tags: From f99ef0ee6b867e19bc4356b5362fd7bb43f18303 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 2 Aug 2024 22:08:49 -0500 Subject: [PATCH 200/296] fix: id in Taggable protocol --- tableauserverclient/server/endpoint/resource_tagger.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index 8796fdd10..b837f644d 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -65,9 +65,12 @@ def update_tags(self, baseurl, resource_item): @runtime_checkable class Taggable(Protocol): _initial_tags: Set[str] - id: Optional[str] = None tags: Set[str] + @property + def id(self) -> Optional[str]: + pass + class Response(Protocol): content: bytes From 8875c8b642cd511b6927e605c4087f427e25f932 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 2 Aug 2024 22:09:07 -0500 Subject: [PATCH 201/296] chore: replace resource_tagger with mixin call --- .../server/endpoint/workbooks_endpoint.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 3051ca6b6..7b06ae305 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -24,11 +24,9 @@ from tableauserverclient.server import RequestFactory from typing import ( - Iterable, List, Optional, Sequence, - Set, Tuple, TYPE_CHECKING, Union, @@ -37,8 +35,8 @@ if TYPE_CHECKING: from tableauserverclient.server import Server from tableauserverclient.server.request_options import RequestOptions - from tableauserverclient.models import DatasourceItem, ConnectionCredentials - from .schedules_endpoint import AddResponse + from tableauserverclient.models import DatasourceItem + from tableauserverclient.server.endpoint.schedules_endpoint import AddResponse io_types_r = (io.BytesIO, io.BufferedReader) io_types_w = (io.BytesIO, io.BufferedWriter) @@ -61,7 +59,6 @@ class Workbooks(QuerysetEndpoint[WorkbookItem], TaggingMixin): def __init__(self, parent_srv: "Server") -> None: super(Workbooks, self).__init__(parent_srv) - self._resource_tagger = _ResourceTagger(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) return None @@ -149,7 +146,7 @@ def update( error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) - self._resource_tagger.update_tags(self.baseurl, workbook_item) + self.update_tags(workbook_item) # Update the workbook itself url = "{0}/{1}".format(self.baseurl, workbook_item.id) From da4eb907e8cf6a574c20e008b1954075f365687c Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 2 Aug 2024 22:19:11 -0500 Subject: [PATCH 202/296] chore: switch to using mixin methods --- tableauserverclient/server/endpoint/datasources_endpoint.py | 5 ++--- tableauserverclient/server/endpoint/views_endpoint.py | 5 ++--- tableauserverclient/server/endpoint/workbooks_endpoint.py | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index d15c5ee20..45cfb005d 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -19,7 +19,7 @@ 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, TaggingMixin +from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, FILESIZE_LIMIT_MB, BYTES_PER_MB, CHUNK_SIZE_MB from tableauserverclient.filesys_helpers import ( @@ -57,7 +57,6 @@ class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin): def __init__(self, parent_srv: "Server") -> None: super(Datasources, self).__init__(parent_srv) - self._resource_tagger = _ResourceTagger(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "datasource") @@ -149,7 +148,7 @@ def update(self, datasource_item: DatasourceItem) -> DatasourceItem: ) raise MissingRequiredFieldError(error) - self._resource_tagger.update_tags(self.baseurl, datasource_item) + self.update_tags(datasource_item) # Update the datasource itself url = "{0}/{1}".format(self.baseurl, datasource_item.id) diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index a95f9bf60..fe99b3b3f 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -4,7 +4,7 @@ from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint -from tableauserverclient.server.endpoint.resource_tagger import _ResourceTagger, TaggingMixin +from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin from tableauserverclient.models import ViewItem, PaginationItem from tableauserverclient.helpers.logging import logger @@ -24,7 +24,6 @@ class Views(QuerysetEndpoint[ViewItem], TaggingMixin): def __init__(self, parent_srv): super(Views, self).__init__(parent_srv) - self._resource_tagger = _ResourceTagger(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) # Used because populate_preview_image functionaliy requires workbook endpoint @@ -169,7 +168,7 @@ def update(self, view_item: ViewItem) -> ViewItem: error = "View item missing ID. View must be retrieved from server first." raise MissingRequiredFieldError(error) - self._resource_tagger.update_tags(self.baseurl, view_item) + self.update_tags(view_item) # Returning view item to stay consistent with datasource/view update functions return view_item diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 7b06ae305..9c664e204 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -11,7 +11,7 @@ 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, TaggingMixin +from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin from tableauserverclient.filesys_helpers import ( to_filename, From 1a9a5abc58474e7233c757c882a6f4c8b5afb959 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 2 Aug 2024 22:29:22 -0500 Subject: [PATCH 203/296] refactor: tag test parameter extraction --- test/test_tagging.py | 49 +++++++++++++++----------------------------- 1 file changed, 16 insertions(+), 33 deletions(-) diff --git a/test/test_tagging.py b/test/test_tagging.py index 82936893c..4263f1380 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -77,8 +77,7 @@ def make_database() -> TSC.DatabaseItem: return database -@pytest.mark.parametrize( - "endpoint_type, item", +sample_taggable_items = ( [ ("workbooks", make_workbook()), ("workbooks", "some_id"), @@ -92,13 +91,16 @@ def make_database() -> TSC.DatabaseItem: ("databases", "some_id"), ], ) -@pytest.mark.parametrize( - "tags", - [ - "a", - ["a", "b"], - ], -) + +sample_tags = [ + "a", + ["a", "b"], + ["a", "b", "c", "c"], +] + + +@pytest.mark.parametrize("endpoint_type, item", *sample_taggable_items) +@pytest.mark.parametrize("tags", sample_tags) def test_add_tags(get_server, endpoint_type, item, tags) -> None: add_tags_xml = add_tag_xml_response_factory(tags) endpoint = getattr(get_server, endpoint_type) @@ -117,28 +119,8 @@ def test_add_tags(get_server, endpoint_type, item, tags) -> None: assert set(tag_result) == set(tags) -@pytest.mark.parametrize( - "endpoint_type, item", - [ - ("workbooks", make_workbook()), - ("workbooks", "some_id"), - ("views", make_view()), - ("views", "some_id"), - ("datasources", make_datasource()), - ("datasources", "some_id"), - ("tables", make_table()), - ("tables", "some_id"), - ("databases", make_database()), - ("databases", "some_id"), - ], -) -@pytest.mark.parametrize( - "tags", - [ - "a", - ["a", "b"], - ], -) +@pytest.mark.parametrize("endpoint_type, item", *sample_taggable_items) +@pytest.mark.parametrize("tags", sample_tags) def test_delete_tags(get_server, endpoint_type, item, tags) -> None: add_tags_xml = add_tag_xml_response_factory(tags) endpoint = getattr(get_server, endpoint_type) @@ -158,9 +140,10 @@ def test_delete_tags(get_server, endpoint_type, item, tags) -> None: endpoint.delete_tags(item, tags) history = m.request_history - assert len(history) == len(tags) + tag_set = set(tags) + assert len(history) == len(tag_set) urls = {r.url.split("/")[-1] for r in history} - assert urls == set(tags) + assert urls == tag_set def test_tags_batch_add(get_server) -> None: From 28503b501dd8d4bb39859c320ea439794bba41be Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 2 Aug 2024 22:40:32 -0500 Subject: [PATCH 204/296] feat: add tagging to flows --- tableauserverclient/server/endpoint/flows_endpoint.py | 4 ++-- test/test_tagging.py | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 858ff91ac..dd19fc0ef 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -13,7 +13,7 @@ from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api 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.server.endpoint.resource_tagger import _ResourceTagger, TaggingMixin from tableauserverclient.models import FlowItem, PaginationItem, ConnectionItem, JobItem from tableauserverclient.server import RequestFactory from tableauserverclient.filesys_helpers import ( @@ -50,7 +50,7 @@ PathOrFileW = Union[FilePath, FileObjectW] -class Flows(QuerysetEndpoint[FlowItem]): +class Flows(QuerysetEndpoint[FlowItem], TaggingMixin): def __init__(self, parent_srv): super(Flows, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) diff --git a/test/test_tagging.py b/test/test_tagging.py index 4263f1380..d3f23d40e 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -77,6 +77,12 @@ def make_database() -> TSC.DatabaseItem: return database +def make_flow() -> TSC.FlowItem: + flow = TSC.FlowItem("project", "test") + flow._id = str(uuid.uuid4()) + return flow + + sample_taggable_items = ( [ ("workbooks", make_workbook()), @@ -89,6 +95,8 @@ def make_database() -> TSC.DatabaseItem: ("tables", "some_id"), ("databases", make_database()), ("databases", "some_id"), + ("flows", make_flow()), + ("flows", "some_id"), ], ) From cf7bce7412ad6bd2bb06542c999189590ccffe55 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sat, 3 Aug 2024 06:37:21 -0500 Subject: [PATCH 205/296] chore: use overrides to apply decorator --- .../server/endpoint/databases_endpoint.py | 14 +++++++++++--- .../server/endpoint/datasources_endpoint.py | 15 +++++++++++---- .../server/endpoint/resource_tagger.py | 10 ++++++++-- .../server/endpoint/tables_endpoint.py | 13 ++++++++++--- .../server/endpoint/views_endpoint.py | 17 ++++++++++++----- .../server/endpoint/workbooks_endpoint.py | 15 ++++++++++++--- 6 files changed, 64 insertions(+), 20 deletions(-) diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index ecdb3f334..2f8fece07 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -1,4 +1,5 @@ import logging +from typing import Union, Iterable, Set from tableauserverclient.server.endpoint.default_permissions_endpoint import _DefaultPermissionsEndpoint from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint @@ -125,7 +126,14 @@ def add_dqw(self, item, warning): def delete_dqw(self, item): self._data_quality_warnings.clear(item) + @api(version="3.9") + def add_tags(self, item: Union[DatabaseItem, str], tags: Iterable[str]) -> Set[str]: + return super().add_tags(item, tags) -Databases.add_tags = api(version="3.9")(Databases.add_tags) # type: ignore -Databases.delete_tags = api(version="3.9")(Databases.delete_tags) # type: ignore -Databases.update_tags = api(version="3.9")(Databases.update_tags) # type: ignore + @api(version="3.9") + def delete_tags(self, item: Union[DatabaseItem, str], tags: Iterable[str]) -> None: + super().delete_tags(item, tags) + + @api(version="3.9") + def update_tags(self, item: DatabaseItem) -> None: + raise NotImplementedError("Update tags is not supported for databases.") diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 45cfb005d..a528d5e67 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -6,7 +6,7 @@ from contextlib import closing from pathlib import Path -from typing import List, Mapping, Optional, Sequence, Tuple, TYPE_CHECKING, Union +from typing import Iterable, List, Mapping, Optional, Sequence, Set, Tuple, TYPE_CHECKING, Union from tableauserverclient.helpers.headers import fix_filename @@ -459,7 +459,14 @@ def schedule_extract_refresh( ) -> List["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, datasource=item) + @api(version="1.0") + def add_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + return super().add_tags(item, tags) -Datasources.add_tags = api(version="1.0")(Datasources.add_tags) # type: ignore -Datasources.delete_tags = api(version="1.0")(Datasources.delete_tags) # type: ignore -Datasources.update_tags = api(version="1.0")(Datasources.update_tags) # type: ignore + @api(version="1.0") + def delete_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> None: + return super().delete_tags(item, tags) + + @api(version="1.0") + def update_tags(self, item: DatasourceItem) -> None: + return super().update_tags(item) diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index b837f644d..f6b1cab05 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -62,6 +62,12 @@ def update_tags(self, baseurl, resource_item): logger.info("Updated tags to {0}".format(resource_item.tags)) +class HasID(Protocol): + @property + def id(self) -> Optional[str]: + pass + + @runtime_checkable class Taggable(Protocol): _initial_tags: Set[str] @@ -92,7 +98,7 @@ def put_request(self, url, request) -> Response: def delete_request(self, url) -> None: pass - def add_tags(self, item: Union[Taggable, str], tags: Union[Iterable[str], str]) -> Set[str]: + def add_tags(self, item: Union[HasID, Taggable, str], tags: Union[Iterable[str], str]) -> Set[str]: item_id = getattr(item, "id", item) if not isinstance(item_id, str): @@ -108,7 +114,7 @@ def add_tags(self, item: Union[Taggable, str], tags: Union[Iterable[str], str]) server_response = self.put_request(url, add_req) return TagItem.from_response(server_response.content, self.parent_srv.namespace) - def delete_tags(self, item: Union[Taggable, str], tags: Union[Iterable[str], str]) -> None: + def delete_tags(self, item: Union[HasID, Taggable, str], tags: Union[Iterable[str], str]) -> None: item_id = getattr(item, "id", item) if not isinstance(item_id, str): diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index d3b53897f..b2e41df8b 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -1,4 +1,5 @@ import logging +from typing import Iterable, Set, Union from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint from tableauserverclient.server.endpoint.endpoint import api, Endpoint @@ -126,7 +127,13 @@ def add_dqw(self, item, warning): def delete_dqw(self, item): self._data_quality_warnings.clear(item) + @api(version="3.9") + def add_tags(self, item: Union[TableItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + return super().add_tags(item, tags) -Tables.add_tags = api(version="3.9")(Tables.add_tags) # type: ignore -Tables.delete_tags = api(version="3.9")(Tables.delete_tags) # type: ignore -Tables.update_tags = api(version="3.9")(Tables.update_tags) # type: ignore + @api(version="3.9") + def delete_tags(self, item: Union[TableItem, str], tags: Union[Iterable[str], str]) -> None: + return super().delete_tags(item, tags) + + def update_tags(self, item: TableItem) -> None: # type: ignore + raise NotImplementedError("Update tags is not implemented for TableItem") diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index fe99b3b3f..4a4d836e2 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -9,10 +9,10 @@ from tableauserverclient.helpers.logging import logger -from typing import Iterator, List, Optional, Tuple, TYPE_CHECKING +from typing import Iterable, Iterator, List, Optional, Set, Tuple, TYPE_CHECKING, Union if TYPE_CHECKING: - from ..request_options import ( + from tableauserverclient.server.request_options import ( RequestOptions, CSVRequestOptions, PDFRequestOptions, @@ -173,7 +173,14 @@ def update(self, view_item: ViewItem) -> ViewItem: # Returning view item to stay consistent with datasource/view update functions return view_item + @api(version="1.0") + def add_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + return super().add_tags(item, tags) -Views.add_tags = api(version="1.0")(Views.add_tags) # type: ignore -Views.delete_tags = api(version="1.0")(Views.delete_tags) # type: ignore -Views.update_tags = api(version="1.0")(Views.update_tags) # type: ignore + @api(version="1.0") + def delete_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) -> None: + return super().delete_tags(item, tags) + + @api(version="1.0") + def update_tags(self, item: ViewItem) -> None: + return super().update_tags(item) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 9c664e204..9b48ecc15 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -24,9 +24,11 @@ from tableauserverclient.server import RequestFactory from typing import ( + Iterable, List, Optional, Sequence, + Set, Tuple, TYPE_CHECKING, Union, @@ -498,7 +500,14 @@ def schedule_extract_refresh( ) -> List["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, workbook=item) + @api(version="1.0") + def add_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + return super().add_tags(item, tags) -Workbooks.add_tags = api(version="1.0")(Workbooks.add_tags) # type: ignore -Workbooks.delete_tags = api(version="1.0")(Workbooks.delete_tags) # type: ignore -Workbooks.update_tags = api(version="1.0")(Workbooks.update_tags) # type: ignore + @api(version="1.0") + def delete_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> None: + return super().delete_tags(item, tags) + + @api(version="1.0") + def update_tags(self, item: WorkbookItem) -> None: + return super().update_tags(item) From c84f921650ea4298a5333a2e6efbbc2e51776479 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sat, 3 Aug 2024 21:55:44 -0500 Subject: [PATCH 206/296] feat: download custom views --- .../server/endpoint/custom_views_endpoint.py | 34 +++++++++++++++++-- test/test_custom_view.py | 18 ++++++++-- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index d1446b1fe..c2362dde9 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -1,8 +1,10 @@ +import io import logging -from typing import List, Optional, Tuple +import os +from typing import List, Optional, Tuple, Union -from .endpoint import QuerysetEndpoint, api -from .exceptions import MissingRequiredFieldError +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api +from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError from tableauserverclient.models import CustomViewItem, PaginationItem from tableauserverclient.server import RequestFactory, RequestOptions, ImageRequestOptions @@ -16,6 +18,15 @@ update the name or owner of a custom view. """ +FilePath = Union[str, os.PathLike] +FileObject = Union[io.BufferedReader, io.BytesIO] +FileObjectR = Union[io.BufferedReader, io.BytesIO] +FileObjectW = Union[io.BufferedWriter, io.BytesIO] +PathOrFileR = Union[FilePath, FileObjectR] +PathOrFileW = Union[FilePath, FileObjectW] +io_types_r = (io.BufferedReader, io.BytesIO) +io_types_w = (io.BufferedWriter, io.BytesIO) + class CustomViews(QuerysetEndpoint[CustomViewItem]): def __init__(self, parent_srv): @@ -25,6 +36,10 @@ def __init__(self, parent_srv): def baseurl(self) -> str: return "{0}/sites/{1}/customviews".format(self.parent_srv.baseurl, self.parent_srv.site_id) + @property + def expurl(self) -> str: + return f"{self.parent_srv._server_address}/api/exp/sites/{self.parent_srv.site_id}/customviews" + """ If the request has no filter parameters: Administrators will see all custom views. Other users will see only custom views that they own. @@ -102,3 +117,16 @@ def delete(self, view_id: str) -> None: url = "{0}/{1}".format(self.baseurl, view_id) self.delete_request(url) logger.info("Deleted single custom view (ID: {0})".format(view_id)) + + @api(version="3.21") + def download(self, view_item: CustomViewItem, file: PathOrFileW) -> PathOrFileW: + url = f"{self.expurl}/{view_item.id}/content" + server_response = self.get_request(url) + if isinstance(file, io_types_w): + file.write(server_response.content) + return file + + with open(file, "wb") as f: + f.write(server_response.content) + + return file diff --git a/test/test_custom_view.py b/test/test_custom_view.py index 55dec5df1..e1150a6ea 100644 --- a/test/test_custom_view.py +++ b/test/test_custom_view.py @@ -1,4 +1,6 @@ +import io import os +from pathlib import Path import unittest import requests_mock @@ -6,18 +8,19 @@ 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__).parent / "assets" GET_XML = os.path.join(TEST_ASSET_DIR, "custom_view_get.xml") GET_XML_ID = os.path.join(TEST_ASSET_DIR, "custom_view_get_id.xml") POPULATE_PREVIEW_IMAGE = os.path.join(TEST_ASSET_DIR, "Sample View Image.png") CUSTOM_VIEW_UPDATE_XML = os.path.join(TEST_ASSET_DIR, "custom_view_update.xml") +CUSTOM_VIEW_DOWNLOAD = TEST_ASSET_DIR / "custom_view_download.json" class CustomViewTests(unittest.TestCase): def setUp(self): self.server = TSC.Server("http://test", False) - self.server.version = "3.19" # custom views only introduced in 3.19 + self.server.version = "3.21" # custom views only introduced in 3.19 # Fake sign in self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" @@ -132,3 +135,14 @@ def test_update(self) -> None: def test_update_missing_id(self) -> None: cv = TSC.CustomViewItem(name="test") self.assertRaises(TSC.MissingRequiredFieldError, self.server.custom_views.update, cv) + + def test_download(self) -> None: + cv = TSC.CustomViewItem(name="test") + cv._id = "1f951daf-4061-451a-9df1-69a8062664f2" + content = CUSTOM_VIEW_DOWNLOAD.read_bytes() + data = io.BytesIO() + with requests_mock.mock() as m: + m.get(f"{self.server.custom_views.expurl}/1f951daf-4061-451a-9df1-69a8062664f2/content", content=content) + self.server.custom_views.download(cv, data) + + assert data.getvalue() == content From da501d629e5af5543c84ebfcde534b1609547e68 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sun, 4 Aug 2024 14:17:49 -0500 Subject: [PATCH 207/296] feat: publish custom views --- .../server/endpoint/custom_views_endpoint.py | 33 ++++++ tableauserverclient/server/request_factory.py | 44 ++++++++ test/test_custom_view.py | 100 ++++++++++++++++++ 3 files changed, 177 insertions(+) diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index c2362dde9..57a5b0100 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -1,8 +1,11 @@ import io import logging import os +from pathlib import Path from typing import List, Optional, Tuple, Union +from tableauserverclient.config import BYTES_PER_MB, FILESIZE_LIMIT_MB +from tableauserverclient.filesys_helpers import get_file_object_size from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError from tableauserverclient.models import CustomViewItem, PaginationItem @@ -130,3 +133,33 @@ def download(self, view_item: CustomViewItem, file: PathOrFileW) -> PathOrFileW: f.write(server_response.content) return file + + @api(version="3.21") + def publish(self, view_item: CustomViewItem, file: PathOrFileR) -> Optional[CustomViewItem]: + url = self.expurl + if isinstance(file, io_types_r): + size = get_file_object_size(file) + elif isinstance(file, (str, Path)) and (p := Path(file)).is_file(): + size = p.stat().st_size + else: + raise ValueError("File path or file object required for publishing custom view.") + + if size >= FILESIZE_LIMIT_MB * BYTES_PER_MB: + upload_session_id = self.parent_srv.fileuploads.upload(file) + url = f"{url}?uploadSessionId={upload_session_id}" + xml_request, content_type = RequestFactory.CustomView.publish_req_chunked(view_item) + else: + if isinstance(file, io_types_r): + file.seek(0) + contents = file.read() + if view_item.name is None: + raise MissingRequiredFieldError("Custom view item missing name.") + filename = view_item.name + elif isinstance(file, (str, Path)): + filename = Path(file).name + contents = Path(file).read_bytes() + + xml_request, content_type = RequestFactory.CustomView.publish_req(view_item, filename, contents) + + server_response = self.post_request(url, xml_request, content_type) + return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index e965411cf..b0c8b37b0 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1,4 +1,5 @@ import xml.etree.ElementTree as ET + from typing import Any, Dict, Iterable, List, Optional, Tuple, TYPE_CHECKING, Union from requests.packages.urllib3.fields import RequestField @@ -1267,6 +1268,49 @@ def update_req(self, xml_request: ET.Element, custom_view_item: CustomViewItem): if custom_view_item.name is not None: updating_element.attrib["name"] = custom_view_item.name + @_tsrequest_wrapped + def _publish_xml(self, xml_request: ET.Element, custom_view_item: CustomViewItem) -> bytes: + custom_view_element = ET.SubElement(xml_request, "customView") + if (name := custom_view_item.name) is not None: + custom_view_element.attrib["name"] = name + else: + raise ValueError(f"Custom View Item missing name: {custom_view_item}") + if (shared := custom_view_item.shared) is not None: + custom_view_element.attrib["shared"] = str(shared).lower() + else: + raise ValueError(f"Custom View Item missing shared: {custom_view_item}") + if (owner := custom_view_item.owner) is not None: + owner_element = ET.SubElement(custom_view_element, "owner") + if (owner_id := owner.id) is not None: + owner_element.attrib["id"] = owner_id + else: + raise ValueError(f"Custom View Item owner missing id: {owner}") + else: + raise ValueError(f"Custom View Item missing owner: {custom_view_item}") + if (workbook := custom_view_item.workbook) is not None: + workbook_element = ET.SubElement(custom_view_element, "workbook") + if (workbook_id := workbook.id) is not None: + workbook_element.attrib["id"] = workbook_id + else: + raise ValueError(f"Custom View Item workbook missing id: {workbook}") + else: + raise ValueError(f"Custom View Item missing workbook: {custom_view_item}") + + return ET.tostring(xml_request) + + def publish_req_chunked(self, custom_view_item: CustomViewItem): + xml_request = self._publish_xml(custom_view_item) + parts = {"request_payload": ("", xml_request, "text/xml")} + return _add_multipart(parts) + + def publish_req(self, custom_view_item: CustomViewItem, filename: str, file_contents: bytes): + xml_request = self._publish_xml(custom_view_item) + parts = { + "request_payload": ("", xml_request, "text/xml"), + "tableau_customview": (filename, file_contents, "application/octet-stream"), + } + return _add_multipart(parts) + class GroupSetRequest: @_tsrequest_wrapped diff --git a/test/test_custom_view.py b/test/test_custom_view.py index e1150a6ea..80800c86b 100644 --- a/test/test_custom_view.py +++ b/test/test_custom_view.py @@ -1,12 +1,16 @@ +from contextlib import ExitStack import io import os from pathlib import Path +from tempfile import TemporaryDirectory import unittest import requests_mock import tableauserverclient as TSC +from tableauserverclient.config import BYTES_PER_MB from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError TEST_ASSET_DIR = Path(__file__).parent / "assets" @@ -15,6 +19,8 @@ POPULATE_PREVIEW_IMAGE = os.path.join(TEST_ASSET_DIR, "Sample View Image.png") CUSTOM_VIEW_UPDATE_XML = os.path.join(TEST_ASSET_DIR, "custom_view_update.xml") CUSTOM_VIEW_DOWNLOAD = TEST_ASSET_DIR / "custom_view_download.json" +FILE_UPLOAD_INIT = TEST_ASSET_DIR / "fileupload_initialize.xml" +FILE_UPLOAD_APPEND = TEST_ASSET_DIR / "fileupload_append.xml" class CustomViewTests(unittest.TestCase): @@ -146,3 +152,97 @@ def test_download(self) -> None: self.server.custom_views.download(cv, data) assert data.getvalue() == content + + def test_publish_filepath(self) -> None: + cv = TSC.CustomViewItem(name="test") + cv._owner = TSC.UserItem() + cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + cv._workbook = TSC.WorkbookItem() + cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + with requests_mock.mock() as m: + m.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text()) + view = self.server.custom_views.publish(cv, CUSTOM_VIEW_DOWNLOAD) + + assert view is not None + assert isinstance(view, TSC.CustomViewItem) + assert view.id is not None + assert view.name is not None + + def test_publish_file_str(self) -> None: + cv = TSC.CustomViewItem(name="test") + cv._owner = TSC.UserItem() + cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + cv._workbook = TSC.WorkbookItem() + cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + with requests_mock.mock() as m: + m.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text()) + view = self.server.custom_views.publish(cv, str(CUSTOM_VIEW_DOWNLOAD)) + + assert view is not None + assert isinstance(view, TSC.CustomViewItem) + assert view.id is not None + assert view.name is not None + + def test_publish_file_io(self) -> None: + cv = TSC.CustomViewItem(name="test") + cv._owner = TSC.UserItem() + cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + cv._workbook = TSC.WorkbookItem() + cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + data = io.BytesIO(CUSTOM_VIEW_DOWNLOAD.read_bytes()) + with requests_mock.mock() as m: + m.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text()) + view = self.server.custom_views.publish(cv, data) + + assert view is not None + assert isinstance(view, TSC.CustomViewItem) + assert view.id is not None + assert view.name is not None + + def test_publish_missing_owner_id(self) -> None: + cv = TSC.CustomViewItem(name="test") + cv._owner = TSC.UserItem() + cv._workbook = TSC.WorkbookItem() + cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + with requests_mock.mock() as m: + m.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text()) + with self.assertRaises(ValueError): + self.server.custom_views.publish(cv, CUSTOM_VIEW_DOWNLOAD) + + def test_publish_missing_wb_id(self) -> None: + cv = TSC.CustomViewItem(name="test") + cv._owner = TSC.UserItem() + cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + cv._workbook = TSC.WorkbookItem() + with requests_mock.mock() as m: + m.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text()) + with self.assertRaises(ValueError): + self.server.custom_views.publish(cv, CUSTOM_VIEW_DOWNLOAD) + + def test_large_publish(self): + cv = TSC.CustomViewItem(name="test") + cv._owner = TSC.UserItem() + cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + cv._workbook = TSC.WorkbookItem() + cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + with ExitStack() as stack: + temp_dir = stack.enter_context(TemporaryDirectory()) + file_path = Path(temp_dir) / "test_file" + file_path.write_bytes(os.urandom(65 * BYTES_PER_MB)) + mock = stack.enter_context(requests_mock.mock()) + # Mock initializing upload + mock.post(self.server.fileuploads.baseurl, status_code=201, text=FILE_UPLOAD_INIT.read_text()) + # Mock the upload + mock.put( + f"{self.server.fileuploads.baseurl}/7720:170fe6b1c1c7422dadff20f944d58a52-1:0", + text=FILE_UPLOAD_APPEND.read_text(), + ) + # Mock the publish + mock.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text()) + + view = self.server.custom_views.publish(cv, file_path) + + assert view is not None + assert isinstance(view, TSC.CustomViewItem) + assert view.id is not None + assert view.name is not None From 17bd73af797ca99b86b941c64a562cd2942d4ec3 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sun, 4 Aug 2024 14:21:15 -0500 Subject: [PATCH 208/296] fix: add missing test asset --- test/assets/custom_view_download.json | 47 +++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 test/assets/custom_view_download.json diff --git a/test/assets/custom_view_download.json b/test/assets/custom_view_download.json new file mode 100644 index 000000000..1ba2d74b7 --- /dev/null +++ b/test/assets/custom_view_download.json @@ -0,0 +1,47 @@ +[ + { + "isSourceView": true, + "viewName": "Overview", + "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IGRhc2hib2FyZD0nT3ZlcnZpZXcnIHNvdXJjZS1idWlsZD0nMjAyNC4yLjAgKDIwMjQyLjI0LjA3MTYuMTk0NCknIHZlcnNpb249JzE4LjEnIHhtbG5zOnVzZXI9J2h0dHA6Ly93d3cudGFibGVhdXNvZnR3YXJlLmNvbS94bWwvdXNlcic-CiAgPGFjdGl2ZSBpZD0nMScgLz4KICA8ZGF0YXNvdXJjZXM-CiAgICA8ZGF0YXNvdXJjZSBuYW1lPSdmZWRlcmF0ZWQuMTBubms4ZDF2Z213OHExN3l1NzZ1MDZwbmJjaic-CiAgICAgIDxjb2x1bW4gZGF0YXR5cGU9J3N0cmluZycgbmFtZT0nWzpNZWFzdXJlIE5hbWVzXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJz4KICAgICAgICA8YWxpYXNlcz4KICAgICAgICAgIDxhbGlhcyBrZXk9JyZxdW90O1tmZWRlcmF0ZWQuMTBubms4ZDF2Z213OHExN3l1NzZ1MDZwbmJjal0uW2N0ZDpDdXN0b21lciBOYW1lOnFrXSZxdW90OycgdmFsdWU9J0NvdW50IG9mIEN1c3RvbWVycycgLz4KICAgICAgICA8L2FsaWFzZXM-CiAgICAgIDwvY29sdW1uPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChNT05USChPcmRlciBEYXRlKSxQcm9kdWN0IENhdGVnb3J5KScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChNT05USChPcmRlciBEYXRlKSxQcm9kdWN0IENhdGVnb3J5KV0nIG5hbWUtc3R5bGU9J3VucXVhbGlmaWVkJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJz4KICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2Nyb3Nzam9pbic-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbdG1uOk9yZGVyIERhdGU6b2tdJyAvPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW25vbmU6Q2F0ZWdvcnk6bmtdJyAvPgogICAgICAgIDwvZ3JvdXBmaWx0ZXI-CiAgICAgIDwvZ3JvdXA-CiAgICAgIDxjb2x1bW4gY2FwdGlvbj0nQWN0aW9uIChNT05USChPcmRlciBEYXRlKSxQcm9kdWN0IENhdGVnb3J5KScgZGF0YXR5cGU9J3R1cGxlJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKE1PTlRIKE9yZGVyIERhdGUpLFByb2R1Y3QgQ2F0ZWdvcnkpXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJyAvPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChNT05USChPcmRlciBEYXRlKSxTZWdtZW50KScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChNT05USChPcmRlciBEYXRlKSxTZWdtZW50KV0nIG5hbWUtc3R5bGU9J3VucXVhbGlmaWVkJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJz4KICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2Nyb3Nzam9pbic-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbdG1uOk9yZGVyIERhdGU6b2tdJyAvPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW1NlZ21lbnRdJyAvPgogICAgICAgIDwvZ3JvdXBmaWx0ZXI-CiAgICAgIDwvZ3JvdXA-CiAgICAgIDxjb2x1bW4gY2FwdGlvbj0nQWN0aW9uIChNT05USChPcmRlciBEYXRlKSxTZWdtZW50KScgZGF0YXR5cGU9J3R1cGxlJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKE1PTlRIKE9yZGVyIERhdGUpLFNlZ21lbnQpXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJyAvPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChPcmRlciBQcm9maXRhYmxlPyxDYXRlZ29yeSxNT05USChPcmRlciBEYXRlKSknIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoT3JkZXIgUHJvZml0YWJsZT8sQ2F0ZWdvcnksTU9OVEgoT3JkZXIgRGF0ZSkpXScgbmFtZS1zdHlsZT0ndW5xdWFsaWZpZWQnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnPgogICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nY3Jvc3Nqb2luJz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1tDYWxjdWxhdGlvbl85MDYwMTIyMTA0OTQ3NDcxXScgLz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1tDYXRlZ29yeV0nIC8-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbdG1uOk9yZGVyIERhdGU6b2tdJyAvPgogICAgICAgIDwvZ3JvdXBmaWx0ZXI-CiAgICAgIDwvZ3JvdXA-CiAgICAgIDxjb2x1bW4gY2FwdGlvbj0nQWN0aW9uIChPcmRlciBQcm9maXRhYmxlPyxDYXRlZ29yeSxNT05USChPcmRlciBEYXRlKSknIGRhdGF0eXBlPSd0dXBsZScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChPcmRlciBQcm9maXRhYmxlPyxDYXRlZ29yeSxNT05USChPcmRlciBEYXRlKSldJyByb2xlPSdkaW1lbnNpb24nIHR5cGU9J25vbWluYWwnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnIC8-CiAgICAgIDxncm91cCBjYXB0aW9uPSdBY3Rpb24gKE9yZGVyIFByb2ZpdGFibGU_LE1PTlRIKE9yZGVyIERhdGUpLFNlZ21lbnQpJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKE9yZGVyIFByb2ZpdGFibGU_LE1PTlRIKE9yZGVyIERhdGUpLFNlZ21lbnQpXScgbmFtZS1zdHlsZT0ndW5xdWFsaWZpZWQnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnPgogICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nY3Jvc3Nqb2luJz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1tDYWxjdWxhdGlvbl85MDYwMTIyMTA0OTQ3NDcxXScgLz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1t0bW46T3JkZXIgRGF0ZTpva10nIC8-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbU2VnbWVudF0nIC8-CiAgICAgICAgPC9ncm91cGZpbHRlcj4KICAgICAgPC9ncm91cD4KICAgICAgPGNvbHVtbiBjYXB0aW9uPSdBY3Rpb24gKE9yZGVyIFByb2ZpdGFibGU_LE1PTlRIKE9yZGVyIERhdGUpLFNlZ21lbnQpJyBkYXRhdHlwZT0ndHVwbGUnIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoT3JkZXIgUHJvZml0YWJsZT8sTU9OVEgoT3JkZXIgRGF0ZSksU2VnbWVudCldJyByb2xlPSdkaW1lbnNpb24nIHR5cGU9J25vbWluYWwnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnIC8-CiAgICAgIDxncm91cCBjYXB0aW9uPSdBY3Rpb24gKFBvc3RhbCBDb2RlLFN0YXRlL1Byb3ZpbmNlKScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChQb3N0YWwgQ29kZSxTdGF0ZS9Qcm92aW5jZSldJyBuYW1lLXN0eWxlPSd1bnF1YWxpZmllZCcgdXNlcjphdXRvLWNvbHVtbj0nc2hlZXRfbGluayc-CiAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdjcm9zc2pvaW4nPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW25vbmU6UG9zdGFsIENvZGU6bmtdJyAvPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW1N0YXRlL1Byb3ZpbmNlXScgLz4KICAgICAgICA8L2dyb3VwZmlsdGVyPgogICAgICA8L2dyb3VwPgogICAgICA8Y29sdW1uIGNhcHRpb249J0FjdGlvbiAoUG9zdGFsIENvZGUsU3RhdGUvUHJvdmluY2UpJyBkYXRhdHlwZT0ndHVwbGUnIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoUG9zdGFsIENvZGUsU3RhdGUvUHJvdmluY2UpXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJyAvPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChTdGF0ZS9Qcm92aW5jZSknIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoU3RhdGUvUHJvdmluY2UpXScgbmFtZS1zdHlsZT0ndW5xdWFsaWZpZWQnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnPgogICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nY3Jvc3Nqb2luJz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1tTdGF0ZS9Qcm92aW5jZV0nIC8-CiAgICAgICAgPC9ncm91cGZpbHRlcj4KICAgICAgPC9ncm91cD4KICAgICAgPGNvbHVtbiBjYXB0aW9uPSdBY3Rpb24gKFN0YXRlL1Byb3ZpbmNlKScgZGF0YXR5cGU9J3R1cGxlJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKFN0YXRlL1Byb3ZpbmNlKV0nIHJvbGU9J2RpbWVuc2lvbicgdHlwZT0nbm9taW5hbCcgdXNlcjphdXRvLWNvbHVtbj0nc2hlZXRfbGluaycgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tDYWxjdWxhdGlvbl85MDYwMTIyMTA0OTQ3NDcxXScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6Q2FsY3VsYXRpb25fOTA2MDEyMjEwNDk0NzQ3MTpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbT3JkZXIgRGF0ZV0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOk9yZGVyIERhdGU6cWtdJyBwaXZvdD0na2V5JyB0eXBlPSdxdWFudGl0YXRpdmUnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbUmVnaW9uXScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6UmVnaW9uOm5rXScgcGl2b3Q9J2tleScgdHlwZT0nbm9taW5hbCcgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tDYWxjdWxhdGlvbl85OTIxMTAzMTQ0MTAzNzQzXScgZGVyaXZhdGlvbj0nVXNlcicgbmFtZT0nW3VzcjpDYWxjdWxhdGlvbl85OTIxMTAzMTQ0MTAzNzQzOnFrXScgcGl2b3Q9J2tleScgdHlwZT0ncXVhbnRpdGF0aXZlJyAvPgogICAgPC9kYXRhc291cmNlPgogIDwvZGF0YXNvdXJjZXM-CiAgPHdvcmtzaGVldCBuYW1lPSdUb3RhbCBTYWxlcyc-CiAgICA8ZmlsdGVyIGNsYXNzPSdjYXRlZ29yaWNhbCcgY29sdW1uPSdbZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLltBY3Rpb24gKFN0YXRlL1Byb3ZpbmNlKV0nPgogICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J21lbWJlcicgbGV2ZWw9J1tTdGF0ZS9Qcm92aW5jZV0nIG1lbWJlcj0nJnF1b3Q7VGV4YXMmcXVvdDsnIHVzZXI6dWktYWN0aW9uLWZpbHRlcj0nW0FjdGlvbjFdJyB1c2VyOnVpLWRvbWFpbj0nZGF0YWJhc2UnIHVzZXI6dWktZW51bWVyYXRpb249J2luY2x1c2l2ZScgdXNlcjp1aS1tYXJrZXI9J2VudW1lcmF0ZScgLz4KICAgIDwvZmlsdGVyPgogICAgPGZpbHRlciBjbGFzcz0nY2F0ZWdvcmljYWwnIGNvbHVtbj0nW2ZlZGVyYXRlZC4xMG5uazhkMXZnbXc4cTE3eXU3NnUwNnBuYmNqXS5bbm9uZTpSZWdpb246bmtdJyBmaWx0ZXItZ3JvdXA9JzE0Jz4KICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdtZW1iZXInIGxldmVsPSdbbm9uZTpSZWdpb246bmtdJyBtZW1iZXI9JyZxdW90O0NlbnRyYWwmcXVvdDsnIHVzZXI6dWktZG9tYWluPSdkYXRhYmFzZScgdXNlcjp1aS1lbnVtZXJhdGlvbj0naW5jbHVzaXZlJyB1c2VyOnVpLW1hcmtlcj0nZW51bWVyYXRlJyAvPgogICAgPC9maWx0ZXI-CiAgICA8dGFibGUgLz4KICA8L3dvcmtzaGVldD4KICA8d29ya3NoZWV0IG5hbWU9J1NhbGUgTWFwJz4KICAgIDxmaWx0ZXIgY2xhc3M9J2NhdGVnb3JpY2FsJyBjb2x1bW49J1tmZWRlcmF0ZWQuMTBubms4ZDF2Z213OHExN3l1NzZ1MDZwbmJjal0uW25vbmU6UmVnaW9uOm5rXScgZmlsdGVyLWdyb3VwPScxNCc-CiAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbWVtYmVyJyBsZXZlbD0nW25vbmU6UmVnaW9uOm5rXScgbWVtYmVyPScmcXVvdDtDZW50cmFsJnF1b3Q7JyB1c2VyOnVpLWRvbWFpbj0nZGF0YWJhc2UnIHVzZXI6dWktZW51bWVyYXRpb249J2luY2x1c2l2ZScgdXNlcjp1aS1tYXJrZXI9J2VudW1lcmF0ZScgLz4KICAgIDwvZmlsdGVyPgogICAgPHRhYmxlIC8-CiAgPC93b3Jrc2hlZXQ-CiAgPHdvcmtzaGVldCBuYW1lPSdTYWxlcyBieSBTZWdtZW50Jz4KICAgIDxmaWx0ZXIgY2xhc3M9J2NhdGVnb3JpY2FsJyBjb2x1bW49J1tmZWRlcmF0ZWQuMTBubms4ZDF2Z213OHExN3l1NzZ1MDZwbmJjal0uW0FjdGlvbiAoU3RhdGUvUHJvdmluY2UpXSc-CiAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbWVtYmVyJyBsZXZlbD0nW1N0YXRlL1Byb3ZpbmNlXScgbWVtYmVyPScmcXVvdDtUZXhhcyZxdW90OycgdXNlcjp1aS1hY3Rpb24tZmlsdGVyPSdbQWN0aW9uMV0nIHVzZXI6dWktZG9tYWluPSdkYXRhYmFzZScgdXNlcjp1aS1lbnVtZXJhdGlvbj0naW5jbHVzaXZlJyB1c2VyOnVpLW1hcmtlcj0nZW51bWVyYXRlJyAvPgogICAgPC9maWx0ZXI-CiAgICA8ZmlsdGVyIGNsYXNzPSdjYXRlZ29yaWNhbCcgY29sdW1uPSdbZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLltub25lOlJlZ2lvbjpua10nIGZpbHRlci1ncm91cD0nMTQnPgogICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J21lbWJlcicgbGV2ZWw9J1tub25lOlJlZ2lvbjpua10nIG1lbWJlcj0nJnF1b3Q7Q2VudHJhbCZxdW90OycgdXNlcjp1aS1kb21haW49J2RhdGFiYXNlJyB1c2VyOnVpLWVudW1lcmF0aW9uPSdpbmNsdXNpdmUnIHVzZXI6dWktbWFya2VyPSdlbnVtZXJhdGUnIC8-CiAgICA8L2ZpbHRlcj4KICAgIDx0YWJsZSAvPgogIDwvd29ya3NoZWV0PgogIDx3b3Jrc2hlZXQgbmFtZT0nU2FsZXMgYnkgUHJvZHVjdCc-CiAgICA8ZmlsdGVyIGNsYXNzPSdjYXRlZ29yaWNhbCcgY29sdW1uPSdbZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLltBY3Rpb24gKFN0YXRlL1Byb3ZpbmNlKV0nPgogICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J21lbWJlcicgbGV2ZWw9J1tTdGF0ZS9Qcm92aW5jZV0nIG1lbWJlcj0nJnF1b3Q7VGV4YXMmcXVvdDsnIHVzZXI6dWktYWN0aW9uLWZpbHRlcj0nW0FjdGlvbjFdJyB1c2VyOnVpLWRvbWFpbj0nZGF0YWJhc2UnIHVzZXI6dWktZW51bWVyYXRpb249J2luY2x1c2l2ZScgdXNlcjp1aS1tYXJrZXI9J2VudW1lcmF0ZScgLz4KICAgIDwvZmlsdGVyPgogICAgPGZpbHRlciBjbGFzcz0nY2F0ZWdvcmljYWwnIGNvbHVtbj0nW2ZlZGVyYXRlZC4xMG5uazhkMXZnbXc4cTE3eXU3NnUwNnBuYmNqXS5bbm9uZTpSZWdpb246bmtdJyBmaWx0ZXItZ3JvdXA9JzE0Jz4KICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdtZW1iZXInIGxldmVsPSdbbm9uZTpSZWdpb246bmtdJyBtZW1iZXI9JyZxdW90O0NlbnRyYWwmcXVvdDsnIHVzZXI6dWktZG9tYWluPSdkYXRhYmFzZScgdXNlcjp1aS1lbnVtZXJhdGlvbj0naW5jbHVzaXZlJyB1c2VyOnVpLW1hcmtlcj0nZW51bWVyYXRlJyAvPgogICAgPC9maWx0ZXI-CiAgICA8dGFibGUgLz4KICA8L3dvcmtzaGVldD4KICA8d2luZG93cz4KICAgIDx3aW5kb3cgY2xhc3M9J3dvcmtzaGVldCcgbmFtZT0nU2FsZSBNYXAnPgogICAgICA8c2VsZWN0aW9uLWNvbGxlY3Rpb24-CiAgICAgICAgPHR1cGxlLXNlbGVjdGlvbj4KICAgICAgICAgIDx0dXBsZS1yZWZlcmVuY2U-CiAgICAgICAgICAgIDx0dXBsZS1kZXNjcmlwdG9yPgogICAgICAgICAgICAgIDxwYW5lLWRlc2NyaXB0b3I-CiAgICAgICAgICAgICAgICA8eC1maWVsZHM-CiAgICAgICAgICAgICAgICAgIDxmaWVsZD5bZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLltMb25naXR1ZGUgKGdlbmVyYXRlZCldPC9maWVsZD4KICAgICAgICAgICAgICAgIDwveC1maWVsZHM-CiAgICAgICAgICAgICAgICA8eS1maWVsZHM-CiAgICAgICAgICAgICAgICAgIDxmaWVsZD5bZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLltMYXRpdHVkZSAoZ2VuZXJhdGVkKV08L2ZpZWxkPgogICAgICAgICAgICAgICAgPC95LWZpZWxkcz4KICAgICAgICAgICAgICA8L3BhbmUtZGVzY3JpcHRvcj4KICAgICAgICAgICAgICA8Y29sdW1ucz4KICAgICAgICAgICAgICAgIDxmaWVsZD5bZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLltub25lOkNvdW50cnkvUmVnaW9uOm5rXTwvZmllbGQ-CiAgICAgICAgICAgICAgICA8ZmllbGQ-W2ZlZGVyYXRlZC4xMG5uazhkMXZnbXc4cTE3eXU3NnUwNnBuYmNqXS5bbm9uZTpTdGF0ZS9Qcm92aW5jZTpua108L2ZpZWxkPgogICAgICAgICAgICAgICAgPGZpZWxkPltmZWRlcmF0ZWQuMTBubms4ZDF2Z213OHExN3l1NzZ1MDZwbmJjal0uW0dlb21ldHJ5IChnZW5lcmF0ZWQpXTwvZmllbGQ-CiAgICAgICAgICAgICAgICA8ZmllbGQ-W2ZlZGVyYXRlZC4xMG5uazhkMXZnbXc4cTE3eXU3NnUwNnBuYmNqXS5bTGF0aXR1ZGUgKGdlbmVyYXRlZCldPC9maWVsZD4KICAgICAgICAgICAgICAgIDxmaWVsZD5bZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLltMb25naXR1ZGUgKGdlbmVyYXRlZCldPC9maWVsZD4KICAgICAgICAgICAgICAgIDxmaWVsZD5bZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLlt1c3I6Q2FsY3VsYXRpb25fOTkyMTEwMzE0NDEwMzc0Mzpxa108L2ZpZWxkPgogICAgICAgICAgICAgIDwvY29sdW1ucz4KICAgICAgICAgICAgPC90dXBsZS1kZXNjcmlwdG9yPgogICAgICAgICAgICA8dHVwbGU-CiAgICAgICAgICAgICAgPHZhbHVlPiZxdW90O1VuaXRlZCBTdGF0ZXMmcXVvdDs8L3ZhbHVlPgogICAgICAgICAgICAgIDx2YWx1ZT4mcXVvdDtUZXhhcyZxdW90OzwvdmFsdWU-CiAgICAgICAgICAgICAgPHZhbHVlPiZxdW90O01VTFRJUE9MWUdPTigoKC05Ny4xNDYzIDI1Ljk1NTYsLTk3LjIwOCAyNS45NjM2LC05Ny4yNzcyIDI1LjkzNTQsLTk3LjM0ODkgMjUuOTMwOCwtOTcuMzc0NCAyNS45MDc0LC05Ny4zNTc2IDI1Ljg4NjksLTk3LjM3MzcgMjUuODQsLTk3LjQ1MzkgMjUuODU0NCwtOTcuNDU2NCAyNS44ODM4LC05Ny41MjE4IDI1Ljg4NjUsLTk3LjU0ODIgMjUuOTM1NSwtOTcuNTgyNiAyNS45Mzc5LC05Ny42NDQ5IDI2LjAyNzUsLTk3LjcwNjcgMjYuMDM3NCwtOTcuNzY0MSAyNi4wMjg2LC05Ny44MDEzIDI2LjA2LC05Ny44MzU1IDI2LjA0NjksLTk3Ljg2MTkgMjYuMDY5OCwtOTcuOTA5OSAyNi4wNTY5LC05Ny45NjYxIDI2LjA1MTksLTk4LjAzMDggMjYuMDY1LC05OC4wNzAxIDI2LjAzNzksLTk4LjA3OTEgMjYuMDcwNSwtOTguMTM1NSAyNi4wNzIsLTk4LjE1NzUgMjYuMDU0NCwtOTguMTk3IDI2LjA1NjIsLTk4LjMwNjUgMjYuMTA0MywtOTguMzM1MiAyNi4xMzc2LC05OC4zODY3IDI2LjE1NzksLTk4LjQ0NDMgMjYuMjAxMiwtOTguNDQ1MiAyNi4yMjQ2LC05OC41MDYxIDI2LjIwOSwtOTguNTIyNCAyNi4yMjA5LC05OC41NjE1IDI2LjIyNDUsLTk4LjU4NjcgMjYuMjU3NSwtOTguNjU0MiAyNi4yMzYsLTk4LjY3OTQgMjYuMjQ5MiwtOTguNzUzOCAyNi4zMzE3LC05OC43ODk4IDI2LjMzMTYsLTk4LjgyNjkgMjYuMzY5NiwtOTguODk2MiAyNi4zNTMyLC05OC45MjkyIDI2LjM5MzIsLTk4Ljk0NjUgMjYuMzY5OSwtOTguOTc0MiAyNi40MDExLC05OS4wMTA2IDI2LjM5MjEsLTk5LjA0IDI2LjQxMjksLTk5LjA5NDggMjYuNDEwOSwtOTkuMTEwOSAyNi40MjYzLC05OS4wOTE2IDI2LjQ3NjQsLTk5LjEyODQgMjYuNTI1NSwtOTkuMTY2NyAyNi41MzYxLC05OS4xNjk0IDI2LjU3MTcsLTk5LjIwMDIgMjYuNjU1OCwtOTkuMjA4OSAyNi43MjQ4LC05OS4yNCAyNi43NDU5LC05OS4yNDI0IDI2Ljc4ODMsLTk5LjI2ODYgMjYuODQzMiwtOTkuMzI4OSAyNi44ODAyLC05OS4zMjE4IDI2LjkwNjgsLTk5LjM4ODMgMjYuOTQ0MiwtOTkuMzc3MyAyNi45NzM4LC05OS40MTU1IDI3LjAxNzIsLTk5LjQ0NjUgMjcuMDIzLC05OS40NTEgMjcuMDY2OCwtOTkuNDMwMyAyNy4wOTQ5LC05OS40Mzk2IDI3LjE1MjEsLTk5LjQyNjQgMjcuMTc4MywtOTkuNDUzOCAyNy4yNjUxLC05OS40OTY2IDI3LjI3MTcsLTk5LjQ5NSAyNy4zMDM5LC05OS41Mzc5IDI3LjMxNzUsLTk5LjUwNDQgMjcuMzM5OSwtOTkuNDgwNCAyNy40ODE2LC05OS41MjgzIDI3LjQ5ODksLTk5LjUxMTEgMjcuNTY0NSwtOTkuNTU2OCAyNy42MTQzLC05OS41OCAyNy42MDIzLC05OS41OTQgMjcuNjM4NiwtOTkuNjM4OSAyNy42MjY4LC05OS42OTEzIDI3LjY2ODcsLTk5LjcyODQgMjcuNjc5MywtOTkuNzcwNyAyNy43MzIxLC05OS44MzMxIDI3Ljc2MjksLTk5Ljg3MjMgMjcuNzk1MywtOTkuODgxMyAyNy44NDk2LC05OS45MDE1IDI3Ljg2NDIsLTk5LjkwMDEgMjcuOTEyMSwtOTkuOTM3MSAyNy45NDA1LC05OS45MzE4IDI3Ljk4MSwtOTkuOTg5OCAyNy45OTI5LC0xMDAuMDE5IDI4LjA2NjQsLTEwMC4wNTYxIDI4LjA5MTMsLTEwMC4wODY5IDI4LjE0NjgsLTEwMC4xNTkyIDI4LjE2NzYsLTEwMC4yMTIyIDI4LjE5NjgsLTEwMC4yMjM2IDI4LjIzNTIsLTEwMC4yNTc4IDI4LjI0MDMsLTEwMC4yOTM1IDI4LjI3ODUsLTEwMC4yODg2IDI4LjMxNywtMTAwLjM0OTMgMjguNDAxNCwtMTAwLjMzNjIgMjguNDMwMiwtMTAwLjM2ODIgMjguNDc4OSwtMTAwLjMzNDcgMjguNTAwMywtMTAwLjM4NyAyOC41MTQsLTEwMC40MTA0IDI4LjU1NDMsLTEwMC4zOTg1IDI4LjU4NTIsLTEwMC40NDc2IDI4LjYxMDEsLTEwMC40NDU3IDI4LjY0MDYsLTEwMC41MDA0IDI4LjY2MiwtMTAwLjUwNzYgMjguNzQwNiwtMTAwLjUzMzYgMjguNzYxMSwtMTAwLjU0NjYgMjguODI0OSwtMTAwLjU3MDUgMjguODI2MywtMTAwLjU5MTUgMjguODg5MywtMTAwLjY0ODggMjguOTQxLC0xMDAuNjQ1OSAyOC45ODY0LC0xMDAuNjY3NSAyOS4wODQzLC0xMDAuNzc1OSAyOS4xNzMzLC0xMDAuNzY1OSAyOS4xODc1LC0xMDAuNzk0OCAyOS4yNDE2LC0xMDAuODc2MSAyOS4yNzk2LC0xMDAuODg2OCAyOS4zMDc4LC0xMDAuOTUwNyAyOS4zNDc3LC0xMDEuMDA2NiAyOS4zNjYsLTEwMS4wNjAyIDI5LjQ1ODcsLTEwMS4xNTE5IDI5LjQ3NywtMTAxLjE3MzggMjkuNTE0NiwtMTAxLjI2MTIgMjkuNTM2OCwtMTAxLjI0MSAyOS41NjUsLTEwMS4yNjIyIDI5LjYzMDYsLTEwMS4yOTEgMjkuNTcxNSwtMTAxLjMxMTYgMjkuNTg1MSwtMTAxLjMgMjkuNjQwNywtMTAxLjMxNDEgMjkuNjU5MSwtMTAxLjM2MzIgMjkuNjUyNiwtMTAxLjM3NTQgMjkuNzAxOCwtMTAxLjQxNTYgMjkuNzQ2NSwtMTAxLjQ0ODkgMjkuNzUwNywtMTAxLjQ1NTggMjkuNzg4LC0xMDEuNTM5MiAyOS43NjE4LC0xMDEuNTQxOSAyOS44MTA4LC0xMDEuNTc1OCAyOS43NjkzLC0xMDEuNzEwNiAyOS43NjE3LC0xMDEuNzYwOSAyOS43ODIxLC0xMDEuODA2MiAyOS43ODA4LC0xMDEuODUzNCAyOS44MDc5LC0xMDEuOTMzNSAyOS43ODUxLC0xMDIuMDM4MyAyOS44MDMxLC0xMDIuMDQ5IDI5Ljc4NTYsLTEwMi4xMTYxIDI5Ljc5MjUsLTEwMi4xOTQ5IDI5LjgzNzEsLTEwMi4zMjA3IDI5Ljg3ODksLTEwMi4zNjQ4IDI5Ljg0NDMsLTEwMi4zODk3IDI5Ljc4MTksLTEwMi41MTc0IDI5Ljc4MzgsLTEwMi41NDggMjkuNzQ1LC0xMDIuNTcyNCAyOS43NTYxLC0xMDIuNjIzIDI5LjczNjQsLTEwMi42NzQ5IDI5Ljc0NDMsLTEwMi42OTM0IDI5LjY3NzIsLTEwMi43NDIyIDI5LjYzMDcsLTEwMi43NDUgMjkuNTkzMiwtMTAyLjc2ODMgMjkuNTk0NywtMTAyLjc3MTQgMjkuNTQ4OSwtMTAyLjgwODQgMjkuNTIyOSwtMTAyLjgzMSAyOS40NDQzLC0xMDIuODI0NyAyOS4zOTczLC0xMDIuODM5OSAyOS4zNjA2LC0xMDIuODc4NiAyOS4zNTM5LC0xMDIuOTAzMiAyOS4yNTQsLTEwMi44NzA2IDI5LjIzNjksLTEwMi44OTAxIDI5LjIwODgsLTEwMi45NTAyIDI5LjE3MzYsLTEwMi45NzM4IDI5LjE4NTUsLTEwMy4wMzI1IDI5LjEwNDcsLTEwMy4wNzUzIDI5LjA5MjMsLTEwMy4xMDA3IDI5LjA2MDIsLTEwMy4xMTUzIDI4Ljk4NTMsLTEwMy4xNTMzIDI4Ljk3MTgsLTEwMy4yMjc0IDI4Ljk5MTUsLTEwMy4yNzkyIDI4Ljk3NzcsLTEwMy4yOTg2IDI5LjAwNjgsLTEwMy40MzM3IDI5LjA0NSwtMTAzLjQ1MDYgMjkuMDcyOCwtMTAzLjU1NDUgMjkuMTU4NSwtMTAzLjcxOTIgMjkuMTgxNCwtMTAzLjc5MjcgMjkuMjYyMywtMTAzLjgxNDcgMjkuMjczOCwtMTAzLjk2OTYgMjkuMjk3OCwtMTA0LjAxOTkgMjkuMzEyMSwtMTA0LjEwNjUgMjkuMzczMSwtMTA0LjE2MyAyOS4zOTE5LC0xMDQuMjE3NSAyOS40NTU5LC0xMDQuMjA5IDI5LjQ4MSwtMTA0LjI2NDIgMjkuNTE0LC0xMDQuMzM4MSAyOS41MiwtMTA0LjQwMDYgMjkuNTczLC0xMDQuNDY2OSAyOS42MDk2LC0xMDQuNTQ0MiAyOS42ODE2LC0xMDQuNTY2MSAyOS43NzE0LC0xMDQuNjI5NSAyOS44NTIzLC0xMDQuNjgyNSAyOS45MzQ4LC0xMDQuNjc0IDI5Ljk1NjcsLTEwNC43MDYzIDMwLjA0OTcsLTEwNC42ODc5IDMwLjA3MzksLTEwNC42OTY2IDMwLjEzNDQsLTEwNC42ODcyIDMwLjE3OSwtMTA0LjcwNjggMzAuMjM1NCwtMTA0Ljc2MzIgMzAuMjc0NCwtMTA0Ljc3MzUgMzAuMzAyNywtMTA0LjgyMjYgMzAuMzUwMywtMTA0LjgxNjMgMzAuMzc0MywtMTA0Ljg1OTUgMzAuMzkxMSwtMTA0Ljg2OTQgMzAuNDc3MywtMTA0Ljg4MjQgMzAuNTMyMywtMTA0LjkxOSAzMC41OTc3LC0xMDQuOTcyMSAzMC42MTAzLC0xMDUuMDA2NSAzMC42ODU4LC0xMDUuMDYyNSAzMC42ODY2LC0xMDUuMTE4MSAzMC43NDk1LC0xMDUuMTYxNyAzMC43NTIxLC0xMDUuMjE3NyAzMC44MDYsLTEwNS4yNTYxIDMwLjc5NDUsLTEwNS4yOTE3IDMwLjgyNjEsLTEwNS4zNjE1IDMwLjg1MDMsLTEwNS4zOTU2IDMwLjg0OSwtMTA1LjQxMzUgMzAuODk5OCwtMTA1LjQ5ODggMzAuOTUwMywtMTA1LjU3ODYgMzEuMDIwNiwtMTA1LjU4NTEgMzEuMDU2OSwtMTA1LjY0NjcgMzEuMTEzOSwtMTA1Ljc3MzkgMzEuMTY4LC0xMDUuODE4OCAzMS4yMzA3LC0xMDUuODc0NyAzMS4yOTEzLC0xMDUuOTMxMiAzMS4zMTI3LC0xMDUuOTUzOSAzMS4zNjQ3LC0xMDYuMDE2MiAzMS4zOTM1LC0xMDYuMDc1MyAzMS4zOTc2LC0xMDYuMTkxMSAzMS40NTk5LC0xMDYuMjE5NiAzMS40ODE2LC0xMDYuMjQ1MiAzMS41MzkxLC0xMDYuMjgwMSAzMS41NjE1LC0xMDYuMzA3OSAzMS42Mjk1LC0xMDYuMzgxMSAzMS43MzIxLC0xMDYuNDUxNCAzMS43NjQ0LC0xMDYuNDkwNSAzMS43NDg5LC0xMDYuNTI4MiAzMS43ODMxLC0xMDYuNTQ3MSAzMS44MDczLC0xMDYuNjA1MyAzMS44Mjc3LC0xMDYuNjQ1NSAzMS44OTg3LC0xMDYuNjExOCAzMS45MiwtMTA2LjYxODUgMzIuMDAwNSwtMTA1Ljk5OCAzMi4wMDIzLC0xMDUuMjUwNSAzMi4wMDAzLC0xMDQuODQ3OCAzMi4wMDA1LC0xMDQuMDI0NSAzMiwtMTAzLjA2NDQgMzIuMDAwNSwtMTAzLjA2NDcgMzIuOTU5MSwtMTAzLjA1NjcgMzMuMzg4NCwtMTAzLjA0NCAzMy45NzQ2LC0xMDMuMDQyNCAzNS4xODMxLC0xMDMuMDQwOCAzNi4wNTUyLC0xMDMuMDQxOSAzNi41MDA0LC0xMDMuMDAyNCAzNi41MDA0LC0xMDIuMDMyMyAzNi41MDA2LC0xMDEuNjIzOSAzNi40OTk1LC0xMDEuMDg1MiAzNi40OTkyLC0xMDAuMDAwNCAzNi40OTk3LC0xMDAuMDAwNCAzNC43NDY1LC05OS45OTc1IDM0LjU2MDYsLTk5LjkyMzIgMzQuNTc0NiwtOTkuODQ0NiAzNC41MDY5LC05OS43NTM0IDM0LjQyMDksLTk5LjY5NDUgMzQuMzc4MiwtOTkuNiAzNC4zNzQ3LC05OS41Nzk4IDM0LjQxNjksLTk5LjUxNzYgMzQuNDE0NSwtOTkuNDMzNSAzNC4zNzAyLC05OS4zOTg3IDM0LjM3NTgsLTk5LjM5NTIgMzQuNDQyLC05OS4zNzU2IDM0LjQ1ODgsLTk5LjMyMDEgMzQuNDA5MywtOTkuMjYxMyAzNC40MDM1LC05OS4yMTA4IDM0LjMzNjgsLTk5LjE4OTggMzQuMjE0NCwtOTkuMDk1MyAzNC4yMTE4LC05OS4wNDM0IDM0LjE5ODIsLTk4Ljk5MTcgMzQuMjIxNCwtOTguOTUyNCAzNC4yMTI1LC05OC44NjAxIDM0LjE0OTksLTk4LjgzMTEgMzQuMTYyMiwtOTguNzY2NyAzNC4xMzY4LC05OC42OTAxIDM0LjEzMzIsLTk4LjY0ODEgMzQuMTY0NCwtOTguNjEwMiAzNC4xNTcxLC05OC41NjAyIDM0LjEzMzIsLTk4LjQ4NyAzNC4wNjI5LC05OC40MjM1IDM0LjA4MjgsLTk4LjM5ODQgMzQuMTI4NSwtOTguMzY0IDM0LjE1NzEsLTk4LjMwMDIgMzQuMTM0NiwtOTguMjMyNSAzNC4xMzQ2LC05OC4xNjg4IDM0LjExNDMsLTk4LjEzOTEgMzQuMTQxOSwtOTguMTAxOSAzNC4xNDY4LC05OC4wOTA1IDM0LjEyMjUsLTk4LjEyMDIgMzQuMDcyMSwtOTguMDgzOCAzNC4wNDE3LC05OC4wODQ0IDM0LjAwMjksLTk4LjAxNjMgMzMuOTk0MSwtOTcuOTc0MiAzNC4wMDY3LC05Ny45NDY4IDMzLjk5MDksLTk3Ljk3MTIgMzMuOTM3MiwtOTcuOTU3MiAzMy45MTQ1LC05Ny45Nzc5IDMzLjg4OTksLTk3Ljg3MTQgMzMuODQ5LC05Ny44MzQzIDMzLjg1NzcsLTk3Ljc2MyAzMy45MzQxLC05Ny43MzIzIDMzLjkzNjcsLTk3LjY4NzcgMzMuOTg3MiwtOTcuNjYxNSAzMy45OTA4LC05Ny41ODg4IDMzLjk1MTksLTk3LjU4OTMgMzMuOTAzOSwtOTcuNTYwOSAzMy44OTk2LC05Ny40ODQyIDMzLjkxNTQsLTk3LjQ1MTEgMzMuODkxNywtOTcuNDYyOSAzMy44NDI5LC05Ny40NDM5IDMzLjgyMzcsLTk3LjM3MjkgMzMuODE5NSwtOTcuMzMxOSAzMy44ODQ1LC05Ny4yNTU2IDMzLjg2MzcsLTk3LjI0NjIgMzMuOTAwMywtOTcuMjEwMyAzMy45MTU5LC05Ny4xODU1IDMzLjkwMDcsLTk3LjE2NjggMzMuODQwNCwtOTcuMTk3NCAzMy44Mjk4LC05Ny4xOTM0IDMzLjc2MDYsLTk3LjE1MTMgMzMuNzIyNiwtOTcuMTExMSAzMy43MTk0LC05Ny4wODg3IDMzLjczODcsLTk3LjA4OCAzMy44MDg3LC05Ny4wNDggMzMuODE3OSwtOTcuMDg3MyAzMy44Mzk4LC05Ny4wNTczIDMzLjg1NjksLTk3LjAyMzUgMzMuODQ0NSwtOTYuOTg1NiAzMy44ODY1LC05Ni45OTYzIDMzLjk0MjcsLTk2LjkzNDggMzMuOTU0NSwtOTYuODk5NCAzMy45MzM3LC05Ni44ODMgMzMuODY4LC05Ni44NTA2IDMzLjg0NzIsLTk2LjgzMjIgMzMuODc0OCwtOTYuNzc5NiAzMy44NTc5LC05Ni43Njk0IDMzLjgyNzUsLTk2LjcxMzcgMzMuODMxMywtOTYuNjkwNyAzMy44NSwtOTYuNjczNCAzMy45MTIzLC05Ni41ODg1IDMzLjg5NSwtOTYuNjI5IDMzLjg1MjQsLTk2LjU3MzIgMzMuODE5MiwtOTYuNTMyOSAzMy44MjMsLTk2LjUwMDcgMzMuNzcyNiwtOTYuNDIyNiAzMy43NzYsLTk2LjM3OTUgMzMuNzI1OCwtOTYuMzYyMiAzMy42OTE4LC05Ni4zMTg0IDMzLjY5NzEsLTk2LjMwMyAzMy43NTA5LC05Ni4yNzczIDMzLjc2OTcsLTk2LjIzMDQgMzMuNzQ4NSwtOTYuMTc4MSAzMy43NjA1LC05Ni4xNDkyIDMzLjgzNzEsLTk2LjEwMTUgMzMuODQ2NywtOTYuMDQ4OCAzMy44MzY1LC05NS45NDE5IDMzLjg2MSwtOTUuOTMyMSAzMy44ODY1LC05NS44NDMzIDMzLjgzODMsLTk1LjgwNDUgMzMuODYyMiwtOTUuNzY3OSAzMy44NDY4LC05NS43NTY2IDMzLjg5MiwtOTUuNjk0OSAzMy44ODY4LC05NS42Njg2IDMzLjkwNywtOTUuNjI3MyAzMy45MDc4LC05NS41OTc1IDMzLjk0MjMsLTk1LjU1NzcgMzMuOTMwNCwtOTUuNTQzNCAzMy44ODA1LC05NS40NTk4IDMzLjg4OCwtOTUuNDM4MiAzMy44NjcxLC05NS4zMTA1IDMzLjg3NzIsLTk1LjI4MjIgMzMuODc1OSwtOTUuMjcxNCAzMy45MTI2LC05NS4yMTk0IDMzLjk2MTYsLTk1LjE1NTkgMzMuOTM2OCwtOTUuMTI5NiAzMy45MzY3LC05NS4xMTc2IDMzLjkwNDYsLTk1LjA4MjQgMzMuODc5OSwtOTUuMDYwMSAzMy45MDE5LC05NS4wNDkgMzMuODY0MSwtOTQuOTY4OSAzMy44NjA5LC05NC45NTM1IDMzLjgxNjUsLTk0LjkyMzMgMzMuODA4NywtOTQuOTExNSAzMy43Nzg0LC05NC44NDkzIDMzLjczOTYsLTk0LjgyMzQgMzMuNzY5MiwtOTQuODAyMyAzMy43MzI4LC05NC43NzEzIDMzLjc2MDcsLTk0Ljc0NjEgMzMuNzAzLC05NC42ODQ4IDMzLjY4NDQsLTk0LjY2NzkgMzMuNjk0NiwtOTQuNjM5MiAzMy42NjM3LC05NC42MjE0IDMzLjY4MjYsLTk0LjU5MDggMzMuNjQ1NiwtOTQuNTQ2NCAzMy42NiwtOTQuNTIwNCAzMy42MTc1LC05NC40ODU5IDMzLjYzNzksLTk0LjM4OTUgMzMuNTQ2NywtOTQuMzUzNiAzMy41NDQsLTk0LjM0NTUgMzMuNTY3MywtOTQuMzA5NiAzMy41NTE3LC05NC4yNzU5IDMzLjU1OCwtOTQuMjE5MiAzMy41NTYxLC05NC4xODQzIDMzLjU5NDYsLTk0LjE0NzQgMzMuNTY1MiwtOTQuMDgyNCAzMy41NzU3LC05NC4wNDM0IDMzLjU1MjMsLTk0LjA0MyAzMy4wMTkyLC05NC4wNDI3IDMxLjk5OTMsLTk0LjAxNTYgMzEuOTc5OSwtOTMuOTcwOCAzMS45MiwtOTMuOTI5OSAzMS45MTI3LC05My44OTY3IDMxLjg4NTMsLTkzLjg3NDggMzEuODIyMywtOTMuODIyNiAzMS43NzM2LC05My44MzY5IDMxLjc1MDIsLTkzLjc5NDUgMzEuNzAyMSwtOTMuODIxNyAzMS42NzQsLTkzLjgxODcgMzEuNjE0NiwtOTMuODM0OSAzMS41ODYyLC05My43ODUgMzEuNTI2LC05My43MTI1IDMxLjUxMzQsLTkzLjc0OTUgMzEuNDY4NywtOTMuNjkyNiAzMS40MzcyLC05My43MDQ5IDMxLjQxMDksLTkzLjY3NDEgMzEuMzk3NywtOTMuNjY5MSAzMS4zNjU0LC05My42ODc1IDMxLjMxMDgsLTkzLjU5ODQgMzEuMjMxMSwtOTMuNjAwMyAzMS4xNzYyLC05My41NTI2IDMxLjE4NTYsLTkzLjUzOTQgMzEuMTE1MiwtOTMuNTYzMiAzMS4wOTcsLTkzLjUyNzYgMzEuMDc0NSwtOTMuNTA4OSAzMS4wMjkzLC05My41NTYzIDMxLjAwNDEsLTkzLjU2ODQgMzAuOTY5MSwtOTMuNTMyMSAzMC45NTc5LC05My41MjYzIDMwLjkyOTcsLTkzLjU1ODYgMzAuOTEzMiwtOTMuNTUzNiAzMC44MzUxLC05My42MTQ4IDMwLjc1NiwtOTMuNjA3NyAzMC43MTU2LC05My42MzE1IDMwLjY3OCwtOTMuNjgzMSAzMC42NDA4LC05My42Nzg4IDMwLjU5ODYsLTkzLjcyNzUgMzAuNTc0NywtOTMuNzMzOCAzMC41MzE3LC05My42OTc4IDMwLjQ0MzgsLTkzLjc0MTcgMzAuNDAyMywtOTMuNzYyMyAzMC4zNTM3LC05My43NDIxIDMwLjMwMSwtOTMuNzA0NyAzMC4yODk5LC05My43MDcgMzAuMjQzNywtOTMuNzIxIDMwLjIxMDQsLTkzLjY5MjggMzAuMTM1MiwtOTMuNzMyOCAzMC4wODI5LC05My43MjI1IDMwLjA1MDksLTkzLjc1NTEgMzAuMDE1MywtOTMuODcxNyAyOS45ODEsLTkzLjg2OTIgMjkuOTM4LC05My45NTA2IDI5Ljg0OTMsLTkzLjk0NjYgMjkuNzgwMSwtOTMuODM3NyAyOS42NzksLTk0LjAxNDMgMjkuNjc5OCwtOTQuMzU0MyAyOS41NjEsLTk0LjQ5OTEgMjkuNTA2OCwtOTQuNDcwMiAyOS41NTcxLC05NC41NDU5IDI5LjU3MjUsLTk0Ljc2MjUgMjkuNTI0MSwtOTQuNzAzOSAyOS42MzI1LC05NC42OTU3IDI5Ljc1NjUsLTk0LjczODkgMjkuNzkwNiwtOTQuODE0MSAyOS43NTksLTk0Ljg3MjggMjkuNjcxNCwtOTQuOTMwMyAyOS42NzM3LC05NS4wMTY2IDI5LjcyMDUsLTk1LjA3MjYgMjkuODI2MiwtOTUuMDk1NSAyOS43NTc2LC05NC45ODMzIDI5LjY4MjMsLTk0Ljk5ODUgMjkuNjE2NCwtOTUuMDc4OSAyOS41MzUzLC05NS4wMTcgMjkuNTQ4LC05NC45MDk2IDI5LjQ5NjEsLTk0Ljk1MDQgMjkuNDY2NywtOTQuODg1NCAyOS4zODk3LC05NS4wNTc0IDI5LjIwMTMsLTk1LjE0OTYgMjkuMTgwNSwtOTUuMjM0MiAyOC45OTI2LC05NS4zODU2IDI4Ljg2NDYsLTk1LjUwNzIgMjguODI1NCwtOTUuNjUzNyAyOC43NDk5LC05NS42NzI3IDI4Ljc0OTUsLTk1Ljc4NCAyOC42Nzk0LC05NS45MTQ5IDI4LjYzODgsLTk1LjY3NzYgMjguNzQ5NCwtOTUuNzg1MyAyOC43NDcxLC05NS45MjM2IDI4LjcwMTUsLTk1Ljk2MDggMjguNjE1MiwtOTYuMzM1NSAyOC40MzgxLC05Ni4xNDYzIDI4LjU0MjcsLTk1Ljk5MDYgMjguNjAxNiwtOTYuMDM4OCAyOC42NTI4LC05Ni4xNTI0IDI4LjYxMzUsLTk2LjIzNTQgMjguNjQyNywtOTYuMjA3OCAyOC42OTgxLC05Ni4zMjI5IDI4LjY0MTksLTk2LjM4NiAyOC42NzQ4LC05Ni40Mjg0IDI4LjcwNzEsLTk2LjQzNDggMjguNjAzLC05Ni41NjE1IDI4LjY0NTQsLTk2LjU3MzYgMjguNzA1NSwtOTYuNjU5NiAyOC43MjI2LC05Ni42NjE0IDI4LjcwMjYsLTk2LjYxMjEgMjguNjM5NCwtOTYuNjM4NSAyOC41NzE5LC05Ni41NjY3IDI4LjU4MjUsLTk2LjQxNTMgMjguNDYzNywtOTYuNDMyMiAyOC40MzI1LC05Ni42NTAzIDI4LjMzMjUsLTk2LjcwODQgMjguNDA3NSwtOTYuNzg1NyAyOC40NDc2LC05Ni43ODMyIDI4LjQwMDQsLTk2Ljg1ODkgMjguNDE3NiwtOTYuNzkwNSAyOC4zMTkyLC05Ni44MDk1IDI4LjIxOTksLTk2LjkxMTEgMjguMTM1NywtOTYuOTg2OCAyOC4xMjg3LC05Ny4wMzczIDI4LjIwMTMsLTk3LjI0MTUgMjguMDYyMywtOTcuMTUgMjguMDMzOCwtOTcuMTM1NCAyOC4wNDcyLC05Ny4wMjQ2IDI4LjExMzMsLTk3LjAzMSAyOC4wNDg2LC05Ny4xMzM4IDI3LjkwMDksLTk3LjE1NjkgMjcuODcyOCwtOTcuMjEzNCAyNy44MjEsLTk3LjI1MDEgMjcuODc2NCwtOTcuMzU0OCAyNy44NTAyLC05Ny4zMzEyIDI3Ljg3MzgsLTk3LjUyODEgMjcuODQ3NCwtOTcuMzgyOSAyNy44Mzg3LC05Ny4zNjE3IDI3LjczNTEsLTk3LjI0NSAyNy42OTMxLC05Ny4zMjQ4IDI3LjU2MSwtOTcuNDEyMyAyNy4zMjI0LC05Ny41MDExIDI3LjI5MTUsLTk3LjQ3MzcgMjcuNDAyOSwtOTcuNTMzOSAyNy4zMzk4LC05Ny42Mzc0IDI3LjMwMSwtOTcuNzM1MiAyNy40MTgyLC05Ny42NjE5IDI3LjI4NzUsLTk3Ljc5NjYgMjcuMjcyNiwtOTcuNjU3NCAyNy4yNzM3LC05Ny41MzQxIDI3LjIyNTMsLTk3LjQ0ODcgMjcuMjYzMSwtOTcuNDUxMSAyNy4xMjE2LC05Ny41MDUyIDI3LjA4NTYsLTk3LjQ3OSAyNi45OTkxLC05Ny41NjE0IDI2Ljk5OCwtOTcuNTYyOSAyNi44Mzg5LC05Ny40NzEgMjYuNzUwMSwtOTcuNDQ2NCAyNi41OTk5LC05Ny40MTc3IDI2LjM3MDIsLTk3LjM0MDYgMjYuMzMxOCwtOTcuMjk1NSAyNi4xOTA4LC05Ny4zMTIxIDI2LjEyMTYsLTk3LjIzNjUgMjYuMDY0NiwtOTcuMjUxNiAyNS45NjQzLC05Ny4xNTI3IDI2LjAyNzUsLTk3LjE0NjMgMjUuOTU1NikpLCgoLTk0LjUxMTcgMjkuNTE1OCwtOTQuNjU5MiAyOS40Mzc1LC05NC43MjgyIDI5LjM3MTYsLTk0Ljc3NzQgMjkuMzc1OSwtOTQuNjg1MiAyOS40NTEzLC05NC41MTE3IDI5LjUxNTgpKSwoKC05NC43NTE4IDI5LjMzMjksLTk0LjgwNDkgMjkuMjc4NywtOTUuMDU2MiAyOS4xMjk5LC05NC44NjEzIDI5LjI5NTMsLTk0Ljc1MTggMjkuMzMyOSkpLCgoLTk2LjgyMDEgMjguMTY0NSwtOTYuNzAzNyAyOC4xOTgsLTk2LjM4NzUgMjguMzc2MiwtOTYuNDQwMyAyOC4zMTg4LC05Ni42ODc4IDI4LjE4NTksLTk2Ljg0NzkgMjguMDY1MSwtOTYuODIwMSAyOC4xNjQ1KSksKCgtOTYuODcyMiAyOC4xMzE1LC05Ni44NSAyOC4wNjM4LC05Ny4wNTU0IDI3Ljg0NzIsLTk2Ljk2MzIgMjguMDIyOSwtOTYuODcyMiAyOC4xMzE1KSksKCgtOTcuMjk0MyAyNi42MDAzLC05Ny4zMjU0IDI2LjYwMDMsLTk3LjMwOTQgMjYuNjI5OCwtOTcuMzkyMSAyNi45MzY3LC05Ny4zOTE2IDI3LjEyNTgsLTk3LjM2NjEgMjcuMjc4MSwtOTcuMzcxMiAyNy4yNzgxLC05Ny4zMzAyIDI3LjQzNTIsLTk3LjI0NzIgMjcuNTgxNSwtOTcuMTk2NCAyNy42ODM3LC05Ny4wOTI1IDI3LjgxMTQsLTk3LjA0NDYgMjcuODM0NCwtOTcuMTUwNCAyNy43MDI3LC05Ny4yMjI3IDI3LjU3NjUsLTk3LjM0NzIgMjcuMjc4LC05Ny4zNzkzIDI3LjA0MDIsLTk3LjM3MDUgMjYuOTA4MSwtOTcuMjkwMSAyNi42MDAzLC05Ny4yOTQzIDI2LjYwMDMpKSkmcXVvdDs8L3ZhbHVlPgogICAgICAgICAgICAgIDx2YWx1ZT4zMS4yNTwvdmFsdWU-CiAgICAgICAgICAgICAgPHZhbHVlPi05OS4yNTwvdmFsdWU-CiAgICAgICAgICAgICAgPHZhbHVlPi0wLjE1MTE4MTkyNDU1MzI0NTk0PC92YWx1ZT4KICAgICAgICAgICAgPC90dXBsZT4KICAgICAgICAgIDwvdHVwbGUtcmVmZXJlbmNlPgogICAgICAgIDwvdHVwbGUtc2VsZWN0aW9uPgogICAgICA8L3NlbGVjdGlvbi1jb2xsZWN0aW9uPgogICAgPC93aW5kb3c-CiAgPC93aW5kb3dzPgo8L2N1c3RvbWl6ZWQtdmlldz4K" + }, + { + "isSourceView": false, + "viewName": "Product", + "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IGRhc2hib2FyZD0nUHJvZHVjdCcgc291cmNlLWJ1aWxkPScyMDI0LjIuMCAoMjAyNDIuMjQuMDcxNi4xOTQ0KScgdmVyc2lvbj0nMTguMScgeG1sbnM6dXNlcj0naHR0cDovL3d3dy50YWJsZWF1c29mdHdhcmUuY29tL3htbC91c2VyJz4KICA8YWN0aXZlIGlkPSctMScgLz4KICA8ZGF0YXNvdXJjZXM-CiAgICA8ZGF0YXNvdXJjZSBuYW1lPSdmZWRlcmF0ZWQuMTBubms4ZDF2Z213OHExN3l1NzZ1MDZwbmJjaic-CiAgICAgIDxncm91cCBjYXB0aW9uPSdBY3Rpb24gKENhdGVnb3J5LFlFQVIoT3JkZXIgRGF0ZSksTU9OVEgoT3JkZXIgRGF0ZSkpJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKENhdGVnb3J5LFlFQVIoT3JkZXIgRGF0ZSksTU9OVEgoT3JkZXIgRGF0ZSkpXScgbmFtZS1zdHlsZT0ndW5xdWFsaWZpZWQnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnPgogICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nY3Jvc3Nqb2luJz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1tDYXRlZ29yeV0nIC8-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbeXI6T3JkZXIgRGF0ZTpva10nIC8-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbbW46T3JkZXIgRGF0ZTpva10nIC8-CiAgICAgICAgPC9ncm91cGZpbHRlcj4KICAgICAgPC9ncm91cD4KICAgICAgPGNvbHVtbiBjYXB0aW9uPSdBY3Rpb24gKENhdGVnb3J5LFlFQVIoT3JkZXIgRGF0ZSksTU9OVEgoT3JkZXIgRGF0ZSkpJyBkYXRhdHlwZT0ndHVwbGUnIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoQ2F0ZWdvcnksWUVBUihPcmRlciBEYXRlKSxNT05USChPcmRlciBEYXRlKSldJyByb2xlPSdkaW1lbnNpb24nIHR5cGU9J25vbWluYWwnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnIC8-CiAgICAgIDxncm91cCBjYXB0aW9uPSdBY3Rpb24gKFlFQVIoT3JkZXIgRGF0ZSksTU9OVEgoT3JkZXIgRGF0ZSkpJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKFlFQVIoT3JkZXIgRGF0ZSksTU9OVEgoT3JkZXIgRGF0ZSkpXScgbmFtZS1zdHlsZT0ndW5xdWFsaWZpZWQnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnPgogICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nY3Jvc3Nqb2luJz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1t5cjpPcmRlciBEYXRlOm9rXScgLz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1ttbjpPcmRlciBEYXRlOm9rXScgLz4KICAgICAgICA8L2dyb3VwZmlsdGVyPgogICAgICA8L2dyb3VwPgogICAgICA8Y29sdW1uIGNhcHRpb249J0FjdGlvbiAoWUVBUihPcmRlciBEYXRlKSxNT05USChPcmRlciBEYXRlKSknIGRhdGF0eXBlPSd0dXBsZScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChZRUFSKE9yZGVyIERhdGUpLE1PTlRIKE9yZGVyIERhdGUpKV0nIHJvbGU9J2RpbWVuc2lvbicgdHlwZT0nbm9taW5hbCcgdXNlcjphdXRvLWNvbHVtbj0nc2hlZXRfbGluaycgLz4KICAgICAgPGdyb3VwIGNhcHRpb249J0FjdGlvbiAoWUVBUihPcmRlciBEYXRlKSxNT05USChPcmRlciBEYXRlKSxQcm9kdWN0IENhdGVnb3J5KScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChZRUFSKE9yZGVyIERhdGUpLE1PTlRIKE9yZGVyIERhdGUpLFByb2R1Y3QgQ2F0ZWdvcnkpXScgbmFtZS1zdHlsZT0ndW5xdWFsaWZpZWQnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnPgogICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nY3Jvc3Nqb2luJz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1t5cjpPcmRlciBEYXRlOm9rXScgLz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1ttbjpPcmRlciBEYXRlOm9rXScgLz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1tub25lOkNhdGVnb3J5Om5rXScgLz4KICAgICAgICA8L2dyb3VwZmlsdGVyPgogICAgICA8L2dyb3VwPgogICAgICA8Y29sdW1uIGNhcHRpb249J0FjdGlvbiAoWUVBUihPcmRlciBEYXRlKSxNT05USChPcmRlciBEYXRlKSxQcm9kdWN0IENhdGVnb3J5KScgZGF0YXR5cGU9J3R1cGxlJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKFlFQVIoT3JkZXIgRGF0ZSksTU9OVEgoT3JkZXIgRGF0ZSksUHJvZHVjdCBDYXRlZ29yeSldJyByb2xlPSdkaW1lbnNpb24nIHR5cGU9J25vbWluYWwnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbT3JkZXIgRGF0ZV0nIGRlcml2YXRpb249J01vbnRoJyBuYW1lPSdbbW46T3JkZXIgRGF0ZTpva10nIHBpdm90PSdrZXknIHR5cGU9J29yZGluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbQ2F0ZWdvcnldJyBkZXJpdmF0aW9uPSdOb25lJyBuYW1lPSdbbm9uZTpDYXRlZ29yeTpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbT3JkZXIgRGF0ZV0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOk9yZGVyIERhdGU6cWtdJyBwaXZvdD0na2V5JyB0eXBlPSdxdWFudGl0YXRpdmUnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbUmVnaW9uXScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6UmVnaW9uOm5rXScgcGl2b3Q9J2tleScgdHlwZT0nbm9taW5hbCcgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tPcmRlciBEYXRlXScgZGVyaXZhdGlvbj0nWWVhcicgbmFtZT0nW3lyOk9yZGVyIERhdGU6b2tdJyBwaXZvdD0na2V5JyB0eXBlPSdvcmRpbmFsJyAvPgogICAgPC9kYXRhc291cmNlPgogIDwvZGF0YXNvdXJjZXM-CiAgPHdvcmtzaGVldCBuYW1lPSdQcm9kdWN0Vmlldyc-CiAgICA8dGFibGUgLz4KICA8L3dvcmtzaGVldD4KICA8d29ya3NoZWV0IG5hbWU9J1Byb2R1Y3REZXRhaWxzJz4KICAgIDx0YWJsZSAvPgogIDwvd29ya3NoZWV0Pgo8L2N1c3RvbWl6ZWQtdmlldz4K" + }, + { + "isSourceView": false, + "viewName": "Customers", + "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IGRhc2hib2FyZD0nQ3VzdG9tZXJzJyBzb3VyY2UtYnVpbGQ9JzIwMjQuMi4wICgyMDI0Mi4yNC4wNzE2LjE5NDQpJyB2ZXJzaW9uPScxOC4xJyB4bWxuczp1c2VyPSdodHRwOi8vd3d3LnRhYmxlYXVzb2Z0d2FyZS5jb20veG1sL3VzZXInPgogIDxhY3RpdmUgaWQ9Jy0xJyAvPgogIDxkYXRhc291cmNlcz4KICAgIDxkYXRhc291cmNlIG5hbWU9J2ZlZGVyYXRlZC4xMG5uazhkMXZnbXc4cTE3eXU3NnUwNnBuYmNqJz4KICAgICAgPGNvbHVtbiBkYXRhdHlwZT0nc3RyaW5nJyBuYW1lPSdbOk1lYXN1cmUgTmFtZXNdJyByb2xlPSdkaW1lbnNpb24nIHR5cGU9J25vbWluYWwnPgogICAgICAgIDxhbGlhc2VzPgogICAgICAgICAgPGFsaWFzIGtleT0nJnF1b3Q7W2ZlZGVyYXRlZC4xMG5uazhkMXZnbXc4cTE3eXU3NnUwNnBuYmNqXS5bY3RkOkN1c3RvbWVyIE5hbWU6cWtdJnF1b3Q7JyB2YWx1ZT0nQ291bnQgb2YgQ3VzdG9tZXJzJyAvPgogICAgICAgIDwvYWxpYXNlcz4KICAgICAgPC9jb2x1bW4-CiAgICAgIDxncm91cCBjYXB0aW9uPSdBY3Rpb24gKFJlZ2lvbiknIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoUmVnaW9uKV0nIG5hbWUtc3R5bGU9J3VucXVhbGlmaWVkJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJz4KICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2Nyb3Nzam9pbic-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbUmVnaW9uXScgLz4KICAgICAgICA8L2dyb3VwZmlsdGVyPgogICAgICA8L2dyb3VwPgogICAgICA8Y29sdW1uIGNhcHRpb249J0FjdGlvbiAoUmVnaW9uKScgZGF0YXR5cGU9J3R1cGxlJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKFJlZ2lvbildJyByb2xlPSdkaW1lbnNpb24nIHR5cGU9J25vbWluYWwnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbQ2F0ZWdvcnldJyBkZXJpdmF0aW9uPSdOb25lJyBuYW1lPSdbbm9uZTpDYXRlZ29yeTpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbT3JkZXIgRGF0ZV0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOk9yZGVyIERhdGU6cWtdJyBwaXZvdD0na2V5JyB0eXBlPSdxdWFudGl0YXRpdmUnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbUmVnaW9uXScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6UmVnaW9uOm5rXScgcGl2b3Q9J2tleScgdHlwZT0nbm9taW5hbCcgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tTZWdtZW50XScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6U2VnbWVudDpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbT3JkZXIgRGF0ZV0nIGRlcml2YXRpb249J1F1YXJ0ZXInIG5hbWU9J1txcjpPcmRlciBEYXRlOm9rXScgcGl2b3Q9J2tleScgdHlwZT0nb3JkaW5hbCcgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tPcmRlciBEYXRlXScgZGVyaXZhdGlvbj0nWWVhcicgbmFtZT0nW3lyOk9yZGVyIERhdGU6b2tdJyBwaXZvdD0na2V5JyB0eXBlPSdvcmRpbmFsJyAvPgogICAgPC9kYXRhc291cmNlPgogIDwvZGF0YXNvdXJjZXM-CiAgPHdvcmtzaGVldCBuYW1lPSdDdXN0b21lclNjYXR0ZXInPgogICAgPHRhYmxlIC8-CiAgPC93b3Jrc2hlZXQ-CiAgPHdvcmtzaGVldCBuYW1lPSdDdXN0b21lclJhbmsnPgogICAgPHRhYmxlIC8-CiAgPC93b3Jrc2hlZXQ-CiAgPHdvcmtzaGVldCBuYW1lPSdDdXN0b21lck92ZXJ2aWV3Jz4KICAgIDx0YWJsZSAvPgogIDwvd29ya3NoZWV0Pgo8L2N1c3RvbWl6ZWQtdmlldz4K" + }, + { + "isSourceView": false, + "viewName": "Shipping", + "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IGRhc2hib2FyZD0nU2hpcHBpbmcnIHNvdXJjZS1idWlsZD0nMjAyNC4yLjAgKDIwMjQyLjI0LjA3MTYuMTk0NCknIHZlcnNpb249JzE4LjEnIHhtbG5zOnVzZXI9J2h0dHA6Ly93d3cudGFibGVhdXNvZnR3YXJlLmNvbS94bWwvdXNlcic-CiAgPGFjdGl2ZSBpZD0nLTEnIC8-CiAgPGRhdGFzb3VyY2VzPgogICAgPGRhdGFzb3VyY2UgbmFtZT0nZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2onPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChEZWxheWVkPyknIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoRGVsYXllZD8pXScgbmFtZS1zdHlsZT0ndW5xdWFsaWZpZWQnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnPgogICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nY3Jvc3Nqb2luJz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1tDYWxjdWxhdGlvbl82NDAxMTAzMTcxMjU5NzIzXScgLz4KICAgICAgICA8L2dyb3VwZmlsdGVyPgogICAgICA8L2dyb3VwPgogICAgICA8Y29sdW1uIGNhcHRpb249J0FjdGlvbiAoRGVsYXllZD8pJyBkYXRhdHlwZT0ndHVwbGUnIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoRGVsYXllZD8pXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJyAvPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChTaGlwIFN0YXR1cyknIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoU2hpcCBTdGF0dXMpXScgbmFtZS1zdHlsZT0ndW5xdWFsaWZpZWQnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnPgogICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nY3Jvc3Nqb2luJz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1tDYWxjdWxhdGlvbl82NDAxMTAzMTcxMjU5NzIzXScgLz4KICAgICAgICA8L2dyb3VwZmlsdGVyPgogICAgICA8L2dyb3VwPgogICAgICA8Y29sdW1uIGNhcHRpb249J0FjdGlvbiAoU2hpcCBTdGF0dXMpJyBkYXRhdHlwZT0ndHVwbGUnIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoU2hpcCBTdGF0dXMpXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJyAvPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChTaGlwIFN0YXR1cyxZRUFSKE9yZGVyIERhdGUpLFdFRUsoT3JkZXIgRGF0ZSkpJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKFNoaXAgU3RhdHVzLFlFQVIoT3JkZXIgRGF0ZSksV0VFSyhPcmRlciBEYXRlKSldJyBuYW1lLXN0eWxlPSd1bnF1YWxpZmllZCcgdXNlcjphdXRvLWNvbHVtbj0nc2hlZXRfbGluayc-CiAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdjcm9zc2pvaW4nPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW0NhbGN1bGF0aW9uXzY0MDExMDMxNzEyNTk3MjNdJyAvPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW3lyOk9yZGVyIERhdGU6b2tdJyAvPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW3R3azpPcmRlciBEYXRlOm9rXScgLz4KICAgICAgICA8L2dyb3VwZmlsdGVyPgogICAgICA8L2dyb3VwPgogICAgICA8Y29sdW1uIGNhcHRpb249J0FjdGlvbiAoU2hpcCBTdGF0dXMsWUVBUihPcmRlciBEYXRlKSxXRUVLKE9yZGVyIERhdGUpKScgZGF0YXR5cGU9J3R1cGxlJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKFNoaXAgU3RhdHVzLFlFQVIoT3JkZXIgRGF0ZSksV0VFSyhPcmRlciBEYXRlKSldJyByb2xlPSdkaW1lbnNpb24nIHR5cGU9J25vbWluYWwnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbQ2FsY3VsYXRpb25fNjQwMTEwMzE3MTI1OTcyM10nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOkNhbGN1bGF0aW9uXzY0MDExMDMxNzEyNTk3MjM6bmtdJyBwaXZvdD0na2V5JyB0eXBlPSdub21pbmFsJyAvPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW1JlZ2lvbl0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOlJlZ2lvbjpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbU2hpcCBNb2RlXScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6U2hpcCBNb2RlOm5rXScgcGl2b3Q9J2tleScgdHlwZT0nbm9taW5hbCcgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tPcmRlciBEYXRlXScgZGVyaXZhdGlvbj0nUXVhcnRlcicgbmFtZT0nW3FyOk9yZGVyIERhdGU6b2tdJyBwaXZvdD0na2V5JyB0eXBlPSdvcmRpbmFsJyAvPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW09yZGVyIERhdGVdJyBkZXJpdmF0aW9uPSdZZWFyJyBuYW1lPSdbeXI6T3JkZXIgRGF0ZTpva10nIHBpdm90PSdrZXknIHR5cGU9J29yZGluYWwnIC8-CiAgICA8L2RhdGFzb3VyY2U-CiAgPC9kYXRhc291cmNlcz4KICA8d29ya3NoZWV0IG5hbWU9J1NoaXBTdW1tYXJ5Jz4KICAgIDx0YWJsZSAvPgogIDwvd29ya3NoZWV0PgogIDx3b3Jrc2hlZXQgbmFtZT0nU2hpcHBpbmdUcmVuZCc-CiAgICA8dGFibGUgLz4KICA8L3dvcmtzaGVldD4KICA8d29ya3NoZWV0IG5hbWU9J0RheXN0b1NoaXAnPgogICAgPHRhYmxlIC8-CiAgPC93b3Jrc2hlZXQ-CjwvY3VzdG9taXplZC12aWV3Pgo=" + }, + { + "isSourceView": false, + "viewName": "Performance", + "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IHNvdXJjZS1idWlsZD0nMjAyNC4yLjAgKDIwMjQyLjI0LjA3MTYuMTk0NCknIHZlcnNpb249JzE4LjEnIHhtbG5zOnVzZXI9J2h0dHA6Ly93d3cudGFibGVhdXNvZnR3YXJlLmNvbS94bWwvdXNlcic-CiAgPGRhdGFzb3VyY2VzPgogICAgPGRhdGFzb3VyY2UgbmFtZT0nZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2onPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW1JlZ2lvbl0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOlJlZ2lvbjpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbT3JkZXIgRGF0ZV0nIGRlcml2YXRpb249J1llYXInIG5hbWU9J1t5cjpPcmRlciBEYXRlOm9rXScgcGl2b3Q9J2tleScgdHlwZT0nb3JkaW5hbCcgLz4KICAgIDwvZGF0YXNvdXJjZT4KICA8L2RhdGFzb3VyY2VzPgogIDx3b3Jrc2hlZXQgbmFtZT0nUGVyZm9ybWFuY2UnPgogICAgPHRhYmxlIC8-CiAgPC93b3Jrc2hlZXQ-CjwvY3VzdG9taXplZC12aWV3Pgo=" + }, + { + "isSourceView": false, + "viewName": "Commission Model", + "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IGRhc2hib2FyZD0nQ29tbWlzc2lvbiBNb2RlbCcgc291cmNlLWJ1aWxkPScyMDI0LjIuMCAoMjAyNDIuMjQuMDcxNi4xOTQ0KScgdmVyc2lvbj0nMTguMScgeG1sbnM6dXNlcj0naHR0cDovL3d3dy50YWJsZWF1c29mdHdhcmUuY29tL3htbC91c2VyJz4KICA8YWN0aXZlIGlkPSctMScgLz4KICA8ZGF0YXNvdXJjZXM-CiAgICA8ZGF0YXNvdXJjZSBuYW1lPSdmZWRlcmF0ZWQuMGEwMWNvZDFveGw4M2wxZjV5dmVzMWNmY2lxbyc-CiAgICAgIDxjb2x1bW4gZGF0YXR5cGU9J3N0cmluZycgbmFtZT0nWzpNZWFzdXJlIE5hbWVzXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJyAvPgogICAgPC9kYXRhc291cmNlPgogIDwvZGF0YXNvdXJjZXM-CiAgPHdvcmtzaGVldCBuYW1lPSdRdW90YUF0dGFpbm1lbnQnPgogICAgPHRhYmxlIC8-CiAgPC93b3Jrc2hlZXQ-CiAgPHdvcmtzaGVldCBuYW1lPSdDb21taXNzaW9uUHJvamVjdGlvbic-CiAgICA8dGFibGUgLz4KICA8L3dvcmtzaGVldD4KICA8d29ya3NoZWV0IG5hbWU9J1NhbGVzJz4KICAgIDx0YWJsZSAvPgogIDwvd29ya3NoZWV0PgogIDx3b3Jrc2hlZXQgbmFtZT0nT1RFJz4KICAgIDx0YWJsZSAvPgogIDwvd29ya3NoZWV0Pgo8L2N1c3RvbWl6ZWQtdmlldz4K" + }, + { + "isSourceView": false, + "viewName": "Order Details", + "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IGRhc2hib2FyZD0nT3JkZXIgRGV0YWlscycgc291cmNlLWJ1aWxkPScyMDI0LjIuMCAoMjAyNDIuMjQuMDcxNi4xOTQ0KScgdmVyc2lvbj0nMTguMScgeG1sbnM6dXNlcj0naHR0cDovL3d3dy50YWJsZWF1c29mdHdhcmUuY29tL3htbC91c2VyJz4KICA8YWN0aXZlIGlkPSctMScgLz4KICA8ZGF0YXNvdXJjZXM-CiAgICA8ZGF0YXNvdXJjZSBuYW1lPSdmZWRlcmF0ZWQuMTBubms4ZDF2Z213OHExN3l1NzZ1MDZwbmJjaic-CiAgICAgIDxjb2x1bW4gZGF0YXR5cGU9J3N0cmluZycgbmFtZT0nWzpNZWFzdXJlIE5hbWVzXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJz4KICAgICAgICA8YWxpYXNlcz4KICAgICAgICAgIDxhbGlhcyBrZXk9JyZxdW90O1tmZWRlcmF0ZWQuMTBubms4ZDF2Z213OHExN3l1NzZ1MDZwbmJjal0uW2N0ZDpDdXN0b21lciBOYW1lOnFrXSZxdW90OycgdmFsdWU9J0NvdW50IG9mIEN1c3RvbWVycycgLz4KICAgICAgICA8L2FsaWFzZXM-CiAgICAgIDwvY29sdW1uPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChPcmRlciBQcm9maXRhYmxlPyxNT05USChPcmRlciBEYXRlKSxTZWdtZW50KScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChPcmRlciBQcm9maXRhYmxlPyxNT05USChPcmRlciBEYXRlKSxTZWdtZW50KV0nIG5hbWUtc3R5bGU9J3VucXVhbGlmaWVkJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJz4KICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2Nyb3Nzam9pbic-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbQ2FsY3VsYXRpb25fOTA2MDEyMjEwNDk0NzQ3MV0nIC8-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbdG1uOk9yZGVyIERhdGU6b2tdJyAvPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW1NlZ21lbnRdJyAvPgogICAgICAgIDwvZ3JvdXBmaWx0ZXI-CiAgICAgIDwvZ3JvdXA-CiAgICAgIDxjb2x1bW4gY2FwdGlvbj0nQWN0aW9uIChPcmRlciBQcm9maXRhYmxlPyxNT05USChPcmRlciBEYXRlKSxTZWdtZW50KScgZGF0YXR5cGU9J3R1cGxlJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKE9yZGVyIFByb2ZpdGFibGU_LE1PTlRIKE9yZGVyIERhdGUpLFNlZ21lbnQpXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJyAvPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChQb3N0YWwgQ29kZSxTdGF0ZS9Qcm92aW5jZSkgMScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChQb3N0YWwgQ29kZSxTdGF0ZS9Qcm92aW5jZSkgMV0nIG5hbWUtc3R5bGU9J3VucXVhbGlmaWVkJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJz4KICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2Nyb3Nzam9pbic-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbUG9zdGFsIENvZGVdJyAvPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW1N0YXRlL1Byb3ZpbmNlXScgLz4KICAgICAgICA8L2dyb3VwZmlsdGVyPgogICAgICA8L2dyb3VwPgogICAgICA8Y29sdW1uIGNhcHRpb249J0FjdGlvbiAoUG9zdGFsIENvZGUsU3RhdGUvUHJvdmluY2UpIDEnIGRhdGF0eXBlPSd0dXBsZScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChQb3N0YWwgQ29kZSxTdGF0ZS9Qcm92aW5jZSkgMV0nIHJvbGU9J2RpbWVuc2lvbicgdHlwZT0nbm9taW5hbCcgdXNlcjphdXRvLWNvbHVtbj0nc2hlZXRfbGluaycgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tDYXRlZ29yeV0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOkNhdGVnb3J5Om5rXScgcGl2b3Q9J2tleScgdHlwZT0nbm9taW5hbCcgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tDaXR5XScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6Q2l0eTpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbT3JkZXIgRGF0ZV0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOk9yZGVyIERhdGU6b2tdJyBwaXZvdD0na2V5JyB0eXBlPSdvcmRpbmFsJyAvPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW09yZGVyIERhdGVdJyBkZXJpdmF0aW9uPSdOb25lJyBuYW1lPSdbbm9uZTpPcmRlciBEYXRlOnFrXScgcGl2b3Q9J2tleScgdHlwZT0ncXVhbnRpdGF0aXZlJyAvPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW1JlZ2lvbl0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOlJlZ2lvbjpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbU2VnbWVudF0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOlNlZ21lbnQ6bmtdJyBwaXZvdD0na2V5JyB0eXBlPSdub21pbmFsJyAvPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW1N0YXRlL1Byb3ZpbmNlXScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6U3RhdGUvUHJvdmluY2U6bmtdJyBwaXZvdD0na2V5JyB0eXBlPSdub21pbmFsJyAvPgogICAgPC9kYXRhc291cmNlPgogIDwvZGF0YXNvdXJjZXM-CiAgPHdvcmtzaGVldCBuYW1lPSdQcm9kdWN0IERldGFpbCBTaGVldCc-CiAgICA8dGFibGUgLz4KICA8L3dvcmtzaGVldD4KPC9jdXN0b21pemVkLXZpZXc-Cg==" + }, + { + "isSourceView": false, + "viewName": "Forecast", + "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IHNvdXJjZS1idWlsZD0nMjAyNC4yLjAgKDIwMjQyLjI0LjA3MTYuMTk0NCknIHZlcnNpb249JzE4LjEnIHhtbG5zOnVzZXI9J2h0dHA6Ly93d3cudGFibGVhdXNvZnR3YXJlLmNvbS94bWwvdXNlcic-CiAgPGRhdGFzb3VyY2VzPgogICAgPGRhdGFzb3VyY2UgbmFtZT0nZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2onPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW09yZGVyIERhdGVdJyBkZXJpdmF0aW9uPSdOb25lJyBuYW1lPSdbbm9uZTpPcmRlciBEYXRlOnFrXScgcGl2b3Q9J2tleScgdHlwZT0ncXVhbnRpdGF0aXZlJyAvPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW1JlZ2lvbl0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOlJlZ2lvbjpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICA8L2RhdGFzb3VyY2U-CiAgPC9kYXRhc291cmNlcz4KICA8d29ya3NoZWV0IG5hbWU9J0ZvcmVjYXN0Jz4KICAgIDx0YWJsZSAvPgogIDwvd29ya3NoZWV0Pgo8L2N1c3RvbWl6ZWQtdmlldz4K" + }, + { + "isSourceView": false, + "viewName": "What If Forecast", + "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IHNvdXJjZS1idWlsZD0nMjAyNC4yLjAgKDIwMjQyLjI0LjA3MTYuMTk0NCknIHZlcnNpb249JzE4LjEnIHhtbG5zOnVzZXI9J2h0dHA6Ly93d3cudGFibGVhdXNvZnR3YXJlLmNvbS94bWwvdXNlcic-CiAgPGRhdGFzb3VyY2VzPgogICAgPGRhdGFzb3VyY2UgbmFtZT0nZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2onPgogICAgICA8Y29sdW1uIGRhdGF0eXBlPSdzdHJpbmcnIG5hbWU9J1s6TWVhc3VyZSBOYW1lc10nIHJvbGU9J2RpbWVuc2lvbicgdHlwZT0nbm9taW5hbCc-CiAgICAgICAgPGFsaWFzZXM-CiAgICAgICAgICA8YWxpYXMga2V5PScmcXVvdDtbZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLltjdGQ6Q3VzdG9tZXIgTmFtZTpxa10mcXVvdDsnIHZhbHVlPSdDb3VudCBvZiBDdXN0b21lcnMnIC8-CiAgICAgICAgPC9hbGlhc2VzPgogICAgICA8L2NvbHVtbj4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tPcmRlciBEYXRlXScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6T3JkZXIgRGF0ZTpxa10nIHBpdm90PSdrZXknIHR5cGU9J3F1YW50aXRhdGl2ZScgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tSZWdpb25dJyBkZXJpdmF0aW9uPSdOb25lJyBuYW1lPSdbbm9uZTpSZWdpb246bmtdJyBwaXZvdD0na2V5JyB0eXBlPSdub21pbmFsJyAvPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW09yZGVyIERhdGVdJyBkZXJpdmF0aW9uPSdZZWFyJyBuYW1lPSdbeXI6T3JkZXIgRGF0ZTpva10nIHBpdm90PSdrZXknIHR5cGU9J29yZGluYWwnIC8-CiAgICA8L2RhdGFzb3VyY2U-CiAgPC9kYXRhc291cmNlcz4KICA8d29ya3NoZWV0IG5hbWU9J1doYXQgSWYgRm9yZWNhc3QnPgogICAgPHRhYmxlIC8-CiAgPC93b3Jrc2hlZXQ-CjwvY3VzdG9taXplZC12aWV3Pgo=" + } +] \ No newline at end of file From fc849b098a34db7144ae25ad9c3c13cbafad414d Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Mon, 5 Aug 2024 21:46:45 -0500 Subject: [PATCH 209/296] docs: add docstrings detailing the filter options --- .../server/endpoint/datasources_endpoint.py | 86 +++++++++++++++++++ .../server/endpoint/flow_runs_endpoint.py | 41 +++++++++ .../server/endpoint/flows_endpoint.py | 38 ++++++++ .../server/endpoint/groups_endpoint.py | 42 +++++++++ .../server/endpoint/groupsets_endpoint.py | 41 +++++++++ .../server/endpoint/jobs_endpoint.py | 57 ++++++++++++ .../server/endpoint/projects_endpoint.py | 44 ++++++++++ .../server/endpoint/users_endpoint.py | 40 +++++++++ .../server/endpoint/views_endpoint.py | 75 ++++++++++++++++ .../server/endpoint/workbooks_endpoint.py | 68 +++++++++++++++ tableauserverclient/server/request_options.py | 1 + 11 files changed, 533 insertions(+) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 316f078a2..471aa380c 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -9,6 +9,7 @@ from typing import List, Mapping, Optional, Sequence, Tuple, TYPE_CHECKING, Union from tableauserverclient.helpers.headers import fix_filename +from tableauserverclient.server.query import QuerySet if TYPE_CHECKING: from tableauserverclient.server import Server @@ -459,3 +460,88 @@ def schedule_extract_refresh( self, schedule_id: str, item: DatasourceItem ) -> List["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, datasource=item) + + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[DatasourceItem]: + """ + Queries the Tableau Server for items using the specified filters. Page + size can be specified to limit the number of items returned in a single + request. If not specified, the default page size is 100. Page size can + be an integer between 1 and 1000. + + No positional arguments are allowed. All filters must be specified as + keyword arguments. If you use the equality operator, you can specify it + through =. If you want to use a different operator, + you can specify it through __=. Field + names can either be in snake_case or camelCase. + + This endpoint supports the following fields and operators: + + + authentication_type=... + authentication_type__in=... + connected_workbook_type=... + connected_workbook_type__gt=... + connected_workbook_type__gte=... + connected_workbook_type__lt=... + connected_workbook_type__lte=... + connection_to=... + connection_to__in=... + connection_type=... + connection_type__in=... + content_url=... + content_url__in=... + created_at=... + created_at__gt=... + created_at__gte=... + created_at__lt=... + created_at__lte=... + database_name=... + database_name__in=... + database_user_name=... + database_user_name__in=... + description=... + description__in=... + favorites_total=... + favorites_total__gt=... + favorites_total__gte=... + favorites_total__lt=... + favorites_total__lte=... + has_alert=... + has_embedded_password=... + has_extracts=... + is_certified=... + is_connectable=... + is_default_port=... + is_hierarchical=... + is_published=... + name=... + name__in=... + owner_domain=... + owner_domain__in=... + owner_email=... + owner_name=... + owner_name__in=... + project_name=... + project_name__in=... + server_name=... + server_name__in=... + server_port=... + size=... + size__gt=... + size__gte=... + size__lt=... + size__lte=... + table_name=... + table_name__in=... + tags=... + tags__in=... + type=... + updated_at=... + updated_at__gt=... + updated_at__gte=... + updated_at__lt=... + updated_at__lte=... + """ + + return super().filter(*invalid, page_size=page_size, **kwargs) + diff --git a/tableauserverclient/server/endpoint/flow_runs_endpoint.py b/tableauserverclient/server/endpoint/flow_runs_endpoint.py index 04aefaeee..43ab5153a 100644 --- a/tableauserverclient/server/endpoint/flow_runs_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_runs_endpoint.py @@ -7,6 +7,7 @@ from tableauserverclient.exponential_backoff import ExponentialBackoffTimer from tableauserverclient.helpers.logging import logger +from tableauserverclient.server.query import QuerySet if TYPE_CHECKING: from tableauserverclient.server.server import Server @@ -78,3 +79,43 @@ def wait_for_job(self, flow_run_id: str, *, timeout: Optional[int] = None) -> Fl raise FlowRunCancelledException(flow_run) else: raise AssertionError("Unexpected status in flow_run", flow_run) + + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[FlowRunItem]: + """ + Queries the Tableau Server for items using the specified filters. Page + size can be specified to limit the number of items returned in a single + request. If not specified, the default page size is 100. Page size can + be an integer between 1 and 1000. + + No positional arguments are allowed. All filters must be specified as + keyword arguments. If you use the equality operator, you can specify it + through =. If you want to use a different operator, + you can specify it through __=. Field + names can either be in snake_case or camelCase. + + This endpoint supports the following fields and operators: + + + complete_at=... + complete_at__gt=... + complete_at__gte=... + complete_at__lt=... + complete_at__lte=... + flow_id=... + flow_id__in=... + progress=... + progress__gt=... + progress__gte=... + progress__lt=... + progress__lte=... + started_at=... + started_at__gt=... + started_at__gte=... + started_at__lt=... + started_at__lte=... + user_id=... + user_id__in=... + """ + + return super().filter(*invalid, page_size=page_size, **kwargs) + diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 858ff91ac..da8aea84b 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -22,6 +22,7 @@ get_file_type, get_file_object_size, ) +from tableauserverclient.server.query import QuerySet io_types_r = (io.BytesIO, io.BufferedReader) io_types_w = (io.BytesIO, io.BufferedWriter) @@ -295,3 +296,40 @@ def schedule_flow_run( self, schedule_id: str, item: FlowItem ) -> List["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, flow=item) + + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[FlowItem]: + """ + Queries the Tableau Server for items using the specified filters. Page + size can be specified to limit the number of items returned in a single + request. If not specified, the default page size is 100. Page size can + be an integer between 1 and 1000. + + No positional arguments are allowed. All filters must be specified as + keyword arguments. If you use the equality operator, you can specify it + through =. If you want to use a different operator, + you can specify it through __=. Field + names can either be in snake_case or camelCase. + + This endpoint supports the following fields and operators: + + + created_at=... + created_at__gt=... + created_at__gte=... + created_at__lt=... + created_at__lte=... + name=... + name__in=... + owner_name=... + project_id=... + project_name=... + project_name__in=... + updated=... + updated__gt=... + updated__gte=... + updated__lt=... + updated__lte=... + """ + + return super().filter(*invalid, page_size=page_size, **kwargs) + diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index 8c1fe02a7..e0b7f6f1b 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -10,6 +10,8 @@ from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union +from tableauserverclient.server.query import QuerySet + if TYPE_CHECKING: from tableauserverclient.server.request_options import RequestOptions @@ -162,3 +164,43 @@ def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]] users = UserItem.from_response(server_response.content, self.parent_srv.namespace) logger.info("Added users to group (ID: {0})".format(group_item.id)) return users + + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[GroupItem]: + """ + Queries the Tableau Server for items using the specified filters. Page + size can be specified to limit the number of items returned in a single + request. If not specified, the default page size is 100. Page size can + be an integer between 1 and 1000. + + No positional arguments are allowed. All filters must be specified as + keyword arguments. If you use the equality operator, you can specify it + through =. If you want to use a different operator, + you can specify it through __=. Field + names can either be in snake_case or camelCase. + + This endpoint supports the following fields and operators: + + + domain_name=... + domain_name__in=... + domain_nickname=... + domain_nickname__in=... + is_external_user_enabled=... + is_local=... + luid=... + luid__in=... + minimum_site_role=... + minimum_site_role__in=... + name__cieq=... + name=... + name__in=... + name__like=... + user_count=... + user_count__gt=... + user_count__gte=... + user_count__lt=... + user_count__lte=... + """ + + return super().filter(*invalid, page_size=page_size, **kwargs) + diff --git a/tableauserverclient/server/endpoint/groupsets_endpoint.py b/tableauserverclient/server/endpoint/groupsets_endpoint.py index d24cab52c..a351a8ce8 100644 --- a/tableauserverclient/server/endpoint/groupsets_endpoint.py +++ b/tableauserverclient/server/endpoint/groupsets_endpoint.py @@ -5,6 +5,7 @@ from tableauserverclient.models.groupset_item import GroupSetItem from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint +from tableauserverclient.server.query import QuerySet from tableauserverclient.server.request_options import RequestOptions from tableauserverclient.server.request_factory import RequestFactory from tableauserverclient.server.endpoint.endpoint import api @@ -85,3 +86,43 @@ def update(self, groupset: GroupSetItem) -> GroupSetItem: server_response = self.put_request(url, request) updated_groupset = GroupSetItem.from_response(server_response.content, self.parent_srv.namespace) return updated_groupset[0] + + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[GroupSetItem]: + """ + Queries the Tableau Server for items using the specified filters. Page + size can be specified to limit the number of items returned in a single + request. If not specified, the default page size is 100. Page size can + be an integer between 1 and 1000. + + No positional arguments are allowed. All filters must be specified as + keyword arguments. If you use the equality operator, you can specify it + through =. If you want to use a different operator, + you can specify it through __=. Field + names can either be in snake_case or camelCase. + + This endpoint supports the following fields and operators: + + + domain_name=... + domain_name__in=... + domain_nickname=... + domain_nickname__in=... + is_external_user_enabled=... + is_local=... + luid=... + luid__in=... + minimum_site_role=... + minimum_site_role__in=... + name__cieq=... + name=... + name__in=... + name__like=... + user_count=... + user_count__gt=... + user_count__gte=... + user_count__lt=... + user_count__lte=... + """ + + return super().filter(*invalid, page_size=page_size, **kwargs) + diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index 74770e22b..2f585e7e4 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -1,5 +1,7 @@ import logging +from tableauserverclient.server.query import QuerySet + from .endpoint import QuerysetEndpoint, api from .exceptions import JobCancelledException, JobFailedException from tableauserverclient.models import JobItem, BackgroundJobItem, PaginationItem @@ -74,3 +76,58 @@ def wait_for_job(self, job_id: Union[str, JobItem], *, timeout: Optional[float] raise JobCancelledException(job) else: raise AssertionError("Unexpected finish_code in job", job) + + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[JobItem]: + """ + Queries the Tableau Server for items using the specified filters. Page + size can be specified to limit the number of items returned in a single + request. If not specified, the default page size is 100. Page size can + be an integer between 1 and 1000. + + No positional arguments are allowed. All filters must be specified as + keyword arguments. If you use the equality operator, you can specify it + through =. If you want to use a different operator, + you can specify it through __=. Field + names can either be in snake_case or camelCase. + + This endpoint supports the following fields and operators: + + + args__has=... + completed_at=... + completed_at__gt=... + completed_at__gte=... + completed_at__lt=... + completed_at__lte=... + created_at=... + created_at__gt=... + created_at__gte=... + created_at__lt=... + created_at__lte=... + job_type=... + job_type__in=... + notes__has=... + priority=... + priority__gt=... + priority__gte=... + priority__lt=... + priority__lte=... + progress=... + progress__gt=... + progress__gte=... + progress__lt=... + progress__lte=... + started_at=... + started_at__gt=... + started_at__gte=... + started_at__lt=... + started_at__lte=... + status=... + subtitle=... + subtitle__has=... + title=... + title__has=... + """ + + return super().filter(*invalid, page_size=page_size, **kwargs) + diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 259f53b14..03c9535e7 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -9,6 +9,8 @@ from typing import List, Optional, Tuple, TYPE_CHECKING +from tableauserverclient.server.query import QuerySet + if TYPE_CHECKING: from tableauserverclient.server.server import Server from tableauserverclient.server.request_options import RequestOptions @@ -154,3 +156,45 @@ def delete_flow_default_permissions(self, item, rule): @api(version="3.4") def delete_lens_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Resource.Lens) + + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[ProjectItem]: + """ + Queries the Tableau Server for items using the specified filters. Page + size can be specified to limit the number of items returned in a single + request. If not specified, the default page size is 100. Page size can + be an integer between 1 and 1000. + + No positional arguments are allowed. All filters must be specified as + keyword arguments. If you use the equality operator, you can specify it + through =. If you want to use a different operator, + you can specify it through __=. Field + names can either be in snake_case or camelCase. + + This endpoint supports the following fields and operators: + + + created_at=... + created_at__gt=... + created_at__gte=... + created_at__lt=... + created_at__lte=... + name=... + name__in=... + owner_domain=... + owner_domain__in=... + owner_email=... + owner_email__in=... + owner_name=... + owner_name__in=... + parent_project_id=... + parent_project_id__in=... + top_level_project=... + updated_at=... + updated_at__gt=... + updated_at__gte=... + updated_at__lt=... + updated_at__lte=... + """ + + return super().filter(*invalid, page_size=page_size, **kwargs) + diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index a84ca7399..0550f5697 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -2,6 +2,8 @@ import logging from typing import List, Optional, Tuple +from tableauserverclient.server.query import QuerySet + from .endpoint import QuerysetEndpoint, api from .exceptions import MissingRequiredFieldError, ServerResponseError from tableauserverclient.server import RequestFactory, RequestOptions @@ -166,3 +168,41 @@ def _get_groups_for_user( group_item = GroupItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) return group_item, pagination_item + + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[UserItem]: + """ + Queries the Tableau Server for items using the specified filters. Page + size can be specified to limit the number of items returned in a single + request. If not specified, the default page size is 100. Page size can + be an integer between 1 and 1000. + + No positional arguments are allowed. All filters must be specified as + keyword arguments. If you use the equality operator, you can specify it + through =. If you want to use a different operator, + you can specify it through __=. Field + names can either be in snake_case or camelCase. + + This endpoint supports the following fields and operators: + + + domain_name=... + domain_name__in=... + friendly_name=... + friendly_name__in=... + is_local=... + last_login=... + last_login__gt=... + last_login__gte=... + last_login__lt=... + last_login__lte=... + luid=... + luid__in=... + name__cieq=... + name=... + name__in=... + site_role=... + site_role__in=... + """ + + return super().filter(*invalid, page_size=page_size, **kwargs) + diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index f98eb1cd7..7be591bcb 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -1,6 +1,8 @@ import logging from contextlib import closing +from tableauserverclient.server.query import QuerySet + from .endpoint import QuerysetEndpoint, api from .exceptions import MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint @@ -173,3 +175,76 @@ def update(self, view_item: ViewItem) -> ViewItem: # Returning view item to stay consistent with datasource/view update functions return view_item + + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[ViewItem]: + """ + Queries the Tableau Server for items using the specified filters. Page + size can be specified to limit the number of items returned in a single + request. If not specified, the default page size is 100. Page size can + be an integer between 1 and 1000. + + No positional arguments are allowed. All filters must be specified as + keyword arguments. If you use the equality operator, you can specify it + through =. If you want to use a different operator, + you can specify it through __=. Field + names can either be in snake_case or camelCase. + + This endpoint supports the following fields and operators: + + + caption=... + caption__in=... + content_url=... + content_url__in=... + created_at=... + created_at__gt=... + created_at__gte=... + created_at__lt=... + created_at__lte=... + favorites_total=... + favorites_total__gt=... + favorites_total__gte=... + favorites_total__lt=... + favorites_total__lte=... + fields=... + fields__in=... + hits_total=... + hits_total__gt=... + hits_total__gte=... + hits_total__lt=... + hits_total__lte=... + name=... + name__in=... + owner_domain=... + owner_domain__in=... + owner_email=... + owner_email__in=... + owner_name=... + project_name=... + project_name__in=... + sheet_number=... + sheet_number__gt=... + sheet_number__gte=... + sheet_number__lt=... + sheet_number__lte=... + sheet_type=... + sheet_type__in=... + tags=... + tags__in=... + title=... + title__in=... + updated_at=... + updated_at__gt=... + updated_at__gte=... + updated_at__lt=... + updated_at__lte=... + view_url_name=... + view_url_name__in=... + workbook_description=... + workbook_description__in=... + workbook_name=... + workbook_name__in=... + """ + + return super().filter(*invalid, page_size=page_size, **kwargs) + diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 30f8ce036..c3652f972 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -7,6 +7,7 @@ from pathlib import Path from tableauserverclient.helpers.headers import fix_filename +from tableauserverclient.server.query import QuerySet from .endpoint import QuerysetEndpoint, api, parameter_added_in from .exceptions import InternalServerError, MissingRequiredFieldError @@ -498,3 +499,70 @@ def schedule_extract_refresh( self, schedule_id: str, item: WorkbookItem ) -> List["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, workbook=item) + + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[WorkbookItem]: + """ + Queries the Tableau Server for items using the specified filters. Page + size can be specified to limit the number of items returned in a single + request. If not specified, the default page size is 100. Page size can + be an integer between 1 and 1000. + + No positional arguments are allowed. All filters must be specified as + keyword arguments. If you use the equality operator, you can specify it + through =. If you want to use a different operator, + you can specify it through __=. Field + names can either be in snake_case or camelCase. + + This endpoint supports the following fields and operators: + + + created_at=... + created_at__gt=... + created_at__gte=... + created_at__lt=... + created_at__lte=... + content_url=... + content_url__in=... + display_tabs=... + favorites_total=... + favorites_total__gt=... + favorites_total__gte=... + favorites_total__lt=... + favorites_total__lte=... + has_alerts=... + has_extracts=... + name=... + name__in=... + owner_domain=... + owner_domain__in=... + owner_email=... + owner_email__in=... + owner_name=... + owner_name__in=... + project_name=... + project_name__in=... + sheet_count=... + sheet_count__gt=... + sheet_count__gte=... + sheet_count__lt=... + sheet_count__lte=... + size=... + size__gt=... + size__gte=... + size__lt=... + size__lte=... + subscriptions_total=... + subscriptions_total__gt=... + subscriptions_total__gte=... + subscriptions_total__lt=... + subscriptions_total__lte=... + tags=... + tags__in=... + updated_at=... + updated_at__gt=... + updated_at__gte=... + updated_at__lt=... + updated_at__lte=... + """ + + return super().filter(*invalid, page_size=page_size, **kwargs) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 5cc06bf9d..54ecf6c54 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -38,6 +38,7 @@ class Operator: LessThanOrEqual = "lte" In = "in" Has = "has" + CaseInsensitiveEquals = "cieq" class Field: Args = "args" From 3a47c28bd0a242365d753f64f753092194ec0c35 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Mon, 5 Aug 2024 21:51:47 -0500 Subject: [PATCH 210/296] style: black --- tableauserverclient/server/endpoint/datasources_endpoint.py | 1 - tableauserverclient/server/endpoint/flow_runs_endpoint.py | 1 - tableauserverclient/server/endpoint/flows_endpoint.py | 1 - tableauserverclient/server/endpoint/groups_endpoint.py | 1 - tableauserverclient/server/endpoint/groupsets_endpoint.py | 1 - tableauserverclient/server/endpoint/jobs_endpoint.py | 1 - tableauserverclient/server/endpoint/projects_endpoint.py | 1 - tableauserverclient/server/endpoint/users_endpoint.py | 1 - tableauserverclient/server/endpoint/views_endpoint.py | 1 - tableauserverclient/server/endpoint/workbooks_endpoint.py | 2 +- 10 files changed, 1 insertion(+), 10 deletions(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 471aa380c..a612adfe0 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -544,4 +544,3 @@ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySe """ return super().filter(*invalid, page_size=page_size, **kwargs) - diff --git a/tableauserverclient/server/endpoint/flow_runs_endpoint.py b/tableauserverclient/server/endpoint/flow_runs_endpoint.py index 43ab5153a..c339a0645 100644 --- a/tableauserverclient/server/endpoint/flow_runs_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_runs_endpoint.py @@ -118,4 +118,3 @@ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySe """ return super().filter(*invalid, page_size=page_size, **kwargs) - diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index da8aea84b..a2458ad87 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -332,4 +332,3 @@ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySe """ return super().filter(*invalid, page_size=page_size, **kwargs) - diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index e0b7f6f1b..8acf31692 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -203,4 +203,3 @@ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySe """ return super().filter(*invalid, page_size=page_size, **kwargs) - diff --git a/tableauserverclient/server/endpoint/groupsets_endpoint.py b/tableauserverclient/server/endpoint/groupsets_endpoint.py index a351a8ce8..06e7cc627 100644 --- a/tableauserverclient/server/endpoint/groupsets_endpoint.py +++ b/tableauserverclient/server/endpoint/groupsets_endpoint.py @@ -125,4 +125,3 @@ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySe """ return super().filter(*invalid, page_size=page_size, **kwargs) - diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index 2f585e7e4..a48a3244c 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -130,4 +130,3 @@ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySe """ return super().filter(*invalid, page_size=page_size, **kwargs) - diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 03c9535e7..565817e37 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -197,4 +197,3 @@ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySe """ return super().filter(*invalid, page_size=page_size, **kwargs) - diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 0550f5697..c4b6418b7 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -205,4 +205,3 @@ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySe """ return super().filter(*invalid, page_size=page_size, **kwargs) - diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 7be591bcb..f8c50caaf 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -247,4 +247,3 @@ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySe """ return super().filter(*invalid, page_size=page_size, **kwargs) - diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index c3652f972..e80fa2daf 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -564,5 +564,5 @@ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySe updated_at__lt=... updated_at__lte=... """ - + return super().filter(*invalid, page_size=page_size, **kwargs) From 78291d6ecac2f49b52294342cebb81c34836e0c4 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 16 Aug 2024 22:26:53 -0500 Subject: [PATCH 211/296] feat: get page and chunk size from env vars --- tableauserverclient/config.py | 18 +++++++++++---- .../server/endpoint/datasources_endpoint.py | 4 ++-- .../server/endpoint/fileuploads_endpoint.py | 4 ++-- tableauserverclient/server/query.py | 3 ++- tableauserverclient/server/request_options.py | 5 ++-- test/test_pager.py | 23 +++++++++++++++++++ 6 files changed, 46 insertions(+), 11 deletions(-) diff --git a/tableauserverclient/config.py b/tableauserverclient/config.py index 1a4a7dc37..4392cac77 100644 --- a/tableauserverclient/config.py +++ b/tableauserverclient/config.py @@ -1,13 +1,23 @@ -# TODO: check for env variables, else set default values +import os ALLOWED_FILE_EXTENSIONS = ["tds", "tdsx", "tde", "hyper", "parquet"] BYTES_PER_MB = 1024 * 1024 -# For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks -CHUNK_SIZE_MB = 5 * 10 # 5MB felt too slow, upped it to 50 - DELAY_SLEEP_SECONDS = 0.1 # The maximum size of a file that can be published in a single request is 64MB FILESIZE_LIMIT_MB = 64 + +class Config: +# For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks + @property + def CHUNK_SIZE_MB(self): + return int(os.getenv("TSC_CHUNK_SIZE_MB", 5 * 10)) # 5MB felt too slow, upped it to 50 + +# Default page size + @property + def PAGE_SIZE(self): + return int(os.getenv("TSC_PAGE_SIZE", 100)) + +config = Config() diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 316f078a2..c795b03a3 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -21,7 +21,7 @@ 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.config import ALLOWED_FILE_EXTENSIONS, FILESIZE_LIMIT_MB, BYTES_PER_MB, config from tableauserverclient.filesys_helpers import ( make_download_path, get_file_type, @@ -272,7 +272,7 @@ def publish( if file_size >= FILESIZE_LIMIT_MB * BYTES_PER_MB: logger.info( "Publishing {} to server with chunking method (datasource over {}MB, chunk size {}MB)".format( - filename, FILESIZE_LIMIT_MB, CHUNK_SIZE_MB + filename, FILESIZE_LIMIT_MB, config.CHUNK_SIZE_MB ) ) upload_session_id = self.parent_srv.fileuploads.upload(file) diff --git a/tableauserverclient/server/endpoint/fileuploads_endpoint.py b/tableauserverclient/server/endpoint/fileuploads_endpoint.py index a0e29e508..0d30797c1 100644 --- a/tableauserverclient/server/endpoint/fileuploads_endpoint.py +++ b/tableauserverclient/server/endpoint/fileuploads_endpoint.py @@ -2,7 +2,7 @@ from tableauserverclient import datetime_helpers as datetime from tableauserverclient.helpers.logging import logger -from tableauserverclient.config import BYTES_PER_MB, CHUNK_SIZE_MB +from tableauserverclient.config import BYTES_PER_MB, config from tableauserverclient.models import FileuploadItem from tableauserverclient.server import RequestFactory @@ -41,7 +41,7 @@ def _read_chunks(self, file): try: while True: - chunked_content = file_content.read(CHUNK_SIZE_MB * BYTES_PER_MB) + chunked_content = file_content.read(config.CHUNK_SIZE_MB * BYTES_PER_MB) if not chunked_content: break yield chunked_content diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index 195139269..bbca612e9 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -1,6 +1,7 @@ from collections.abc import Sized from itertools import count from typing import Iterable, Iterator, List, Optional, Protocol, Tuple, TYPE_CHECKING, TypeVar, overload +from tableauserverclient.config import config from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.filter import Filter from tableauserverclient.server.request_options import RequestOptions @@ -35,7 +36,7 @@ def to_camel_case(word: str) -> str: class QuerySet(Iterable[T], Sized): def __init__(self, model: "QuerysetEndpoint[T]", page_size: Optional[int] = None) -> None: self.model = model - self.request_options = RequestOptions(pagesize=page_size or 100) + self.request_options = RequestOptions(pagesize=page_size or config.PAGE_SIZE) self._result_cache: List[T] = [] self._pagination_item = PaginationItem() diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 5cc06bf9d..563018d1c 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -2,6 +2,7 @@ from typing_extensions import Self +from tableauserverclient.config import config from tableauserverclient.models.property_decorators import property_is_int import logging @@ -115,9 +116,9 @@ class Direction: Desc = "desc" Asc = "asc" - def __init__(self, pagenumber=1, pagesize=100): + def __init__(self, pagenumber=1, pagesize=None): self.pagenumber = pagenumber - self.pagesize = pagesize + self.pagesize = pagesize or config.PAGE_SIZE self.sort = set() self.filter = set() diff --git a/test/test_pager.py b/test/test_pager.py index b60559b2b..6a0b20b2f 100644 --- a/test/test_pager.py +++ b/test/test_pager.py @@ -1,9 +1,11 @@ +import contextlib import os import unittest import requests_mock import tableauserverclient as TSC +from tableauserverclient.config import config TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -11,6 +13,15 @@ GET_XML_PAGE2 = os.path.join(TEST_ASSET_DIR, "workbook_get_page_2.xml") GET_XML_PAGE3 = os.path.join(TEST_ASSET_DIR, "workbook_get_page_3.xml") +@contextlib.contextmanager +def set_env(**environ): + old_environ = dict(os.environ) + os.environ.update(environ) + try: + yield + finally: + os.environ.clear() + os.environ.update(old_environ) class PagerTests(unittest.TestCase): def setUp(self): @@ -88,3 +99,15 @@ def test_pager_with_options(self): # Should have the last workbook wb3 = workbooks.pop() self.assertEqual(wb3.name, "Page3Workbook") + + def test_pager_with_env_var(self) -> None: + with set_env(TSC_PAGE_SIZE="1000"): + assert config.PAGE_SIZE == 1000 + loop = TSC.Pager(self.server.workbooks) + assert loop._options.pagesize == 1000 + + def test_queryset_with_env_var(self) -> None: + with set_env(TSC_PAGE_SIZE="1000"): + assert config.PAGE_SIZE == 1000 + loop = self.server.workbooks.all() + assert loop.request_options.pagesize == 1000 From f2710cdc2d70ed00fbad7a634d9d6f2263997595 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 16 Aug 2024 22:32:34 -0500 Subject: [PATCH 212/296] style: black --- tableauserverclient/config.py | 6 ++++-- test/test_pager.py | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tableauserverclient/config.py b/tableauserverclient/config.py index 4392cac77..63872398f 100644 --- a/tableauserverclient/config.py +++ b/tableauserverclient/config.py @@ -9,15 +9,17 @@ # The maximum size of a file that can be published in a single request is 64MB FILESIZE_LIMIT_MB = 64 + class Config: -# For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks + # For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks @property def CHUNK_SIZE_MB(self): return int(os.getenv("TSC_CHUNK_SIZE_MB", 5 * 10)) # 5MB felt too slow, upped it to 50 -# Default page size + # Default page size @property def PAGE_SIZE(self): return int(os.getenv("TSC_PAGE_SIZE", 100)) + config = Config() diff --git a/test/test_pager.py b/test/test_pager.py index 6a0b20b2f..7659f2725 100644 --- a/test/test_pager.py +++ b/test/test_pager.py @@ -13,6 +13,7 @@ GET_XML_PAGE2 = os.path.join(TEST_ASSET_DIR, "workbook_get_page_2.xml") GET_XML_PAGE3 = os.path.join(TEST_ASSET_DIR, "workbook_get_page_3.xml") + @contextlib.contextmanager def set_env(**environ): old_environ = dict(os.environ) @@ -23,6 +24,7 @@ def set_env(**environ): os.environ.clear() os.environ.update(old_environ) + class PagerTests(unittest.TestCase): def setUp(self): self.server = TSC.Server("http://test", False) @@ -100,13 +102,13 @@ def test_pager_with_options(self): wb3 = workbooks.pop() self.assertEqual(wb3.name, "Page3Workbook") - def test_pager_with_env_var(self) -> None: + def test_pager_with_env_var(self): with set_env(TSC_PAGE_SIZE="1000"): assert config.PAGE_SIZE == 1000 loop = TSC.Pager(self.server.workbooks) assert loop._options.pagesize == 1000 - def test_queryset_with_env_var(self) -> None: + def test_queryset_with_env_var(self): with set_env(TSC_PAGE_SIZE="1000"): assert config.PAGE_SIZE == 1000 loop = self.server.workbooks.all() From afc293ec846dcf23eaa1cb46eb293ccb8bf1fd63 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sat, 17 Aug 2024 06:59:32 -0500 Subject: [PATCH 213/296] test: chunk size env var --- test/test_fileuploads.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test/test_fileuploads.py b/test/test_fileuploads.py index cf0861e24..50a5ef48b 100644 --- a/test/test_fileuploads.py +++ b/test/test_fileuploads.py @@ -1,8 +1,11 @@ +import contextlib +import io import os import unittest import requests_mock +from tableauserverclient.config import BYTES_PER_MB, config from tableauserverclient.server import Server from ._utils import asset @@ -11,6 +14,17 @@ FILEUPLOAD_APPEND = os.path.join(TEST_ASSET_DIR, "fileupload_append.xml") +@contextlib.contextmanager +def set_env(**environ): + old_environ = dict(os.environ) + os.environ.update(environ) + try: + yield + finally: + os.environ.clear() + os.environ.update(old_environ) + + class FileuploadsTests(unittest.TestCase): def setUp(self): self.server = Server("http://test", False) @@ -62,3 +76,14 @@ def test_upload_chunks_file_object(self): actual = self.server.fileuploads.upload(file_content) self.assertEqual(upload_id, actual) + + def test_upload_chunks_config(self): + data = io.BytesIO() + data.write(b"1" * (config.CHUNK_SIZE_MB * BYTES_PER_MB + 1)) + data.seek(0) + with set_env(TSC_CHUNK_SIZE_MB="1"): + chunker = self.server.fileuploads._read_chunks(data) + chunk = next(chunker) + assert len(chunk) == config.CHUNK_SIZE_MB * BYTES_PER_MB + data.seek(0) + assert len(chunk) < len(data.read()) From d4a2ab697aeb66dcee6b013b0581a5a9986d88e6 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 22 Aug 2024 20:34:22 -0500 Subject: [PATCH 214/296] style: black --- tableauserverclient/server/endpoint/datasources_endpoint.py | 3 +-- tableauserverclient/server/endpoint/views_endpoint.py | 3 +-- tableauserverclient/server/endpoint/workbooks_endpoint.py | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 0a8a404f6..c01e57047 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -460,7 +460,6 @@ def schedule_extract_refresh( ) -> List["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, datasource=item) - @api(version="1.0") def add_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> Set[str]: return super().add_tags(item, tags) @@ -472,7 +471,7 @@ def delete_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str @api(version="1.0") def update_tags(self, item: DatasourceItem) -> None: return super().update_tags(item) - + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[DatasourceItem]: """ Queries the Tableau Server for items using the specified filters. Page diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 4ce394a7a..7a8623614 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -186,7 +186,7 @@ def delete_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str @api(version="1.0") def update_tags(self, item: ViewItem) -> None: return super().update_tags(item) - + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[ViewItem]: """ Queries the Tableau Server for items using the specified filters. Page @@ -258,4 +258,3 @@ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySe """ return super().filter(*invalid, page_size=page_size, **kwargs) - diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 0e897d4d0..55f61370f 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -512,7 +512,7 @@ def delete_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], @api(version="1.0") def update_tags(self, item: WorkbookItem) -> None: return super().update_tags(item) - + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[WorkbookItem]: """ Queries the Tableau Server for items using the specified filters. Page @@ -579,4 +579,3 @@ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySe """ return super().filter(*invalid, page_size=page_size, **kwargs) - From a3c9afa55ccdcc03a87ad2a2d039067c5cea42ef Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 22 Aug 2024 20:36:32 -0500 Subject: [PATCH 215/296] chore(typing): flow endpoint tags --- tableauserverclient/server/endpoint/flows_endpoint.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 2adbe1f92..1f80e916b 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -5,7 +5,7 @@ import os from contextlib import closing from pathlib import Path -from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union +from typing import Iterable, List, Optional, Set, TYPE_CHECKING, Tuple, Union from tableauserverclient.helpers.headers import fix_filename @@ -297,6 +297,15 @@ def schedule_flow_run( ) -> List["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, flow=item) + def add_tags(self, item: Union[FlowItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + return super().add_tags(item, tags) + + def delete_tags(self, item: Union[FlowItem, str], tags: Union[Iterable[str], str]) -> None: + return super().delete_tags(item, tags) + + def update_tags(self, item: FlowItem) -> None: + return super().update_tags(item) + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[FlowItem]: """ Queries the Tableau Server for items using the specified filters. Page From 273beb10a97cdb875f3ae7cc9cc00000e4b38e28 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 22 Aug 2024 21:31:41 -0500 Subject: [PATCH 216/296] Revert "chore(typing): flow endpoint tags" This reverts commit a3c9afa55ccdcc03a87ad2a2d039067c5cea42ef. api decorator is masking some problems with mypy, and needs a more in depth investigation than belongs in this branch --- tableauserverclient/server/endpoint/flows_endpoint.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 1f80e916b..2adbe1f92 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -5,7 +5,7 @@ import os from contextlib import closing from pathlib import Path -from typing import Iterable, List, Optional, Set, TYPE_CHECKING, Tuple, Union +from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union from tableauserverclient.helpers.headers import fix_filename @@ -297,15 +297,6 @@ def schedule_flow_run( ) -> List["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, flow=item) - def add_tags(self, item: Union[FlowItem, str], tags: Union[Iterable[str], str]) -> Set[str]: - return super().add_tags(item, tags) - - def delete_tags(self, item: Union[FlowItem, str], tags: Union[Iterable[str], str]) -> None: - return super().delete_tags(item, tags) - - def update_tags(self, item: FlowItem) -> None: - return super().update_tags(item) - def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[FlowItem]: """ Queries the Tableau Server for items using the specified filters. Page From ffa0601901203e0c3cb90e45748bb1a896603cf0 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 23 Aug 2024 10:34:47 -0500 Subject: [PATCH 217/296] chore(typing): endpoint decorators --- .../server/endpoint/datasources_endpoint.py | 4 +- .../server/endpoint/endpoint.py | 43 +++++++++++++------ .../server/endpoint/jobs_endpoint.py | 21 ++++++--- .../server/endpoint/workbooks_endpoint.py | 2 +- 4 files changed, 49 insertions(+), 21 deletions(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 77d898d86..caa591fac 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -126,7 +126,7 @@ def download( datasource_id: str, filepath: Optional[PathOrFileW] = None, include_extract: bool = True, - ) -> str: + ) -> PathOrFileW: return self.download_revision( datasource_id, None, @@ -405,7 +405,7 @@ def _get_datasource_revisions( def download_revision( self, datasource_id: str, - revision_number: str, + revision_number: Optional[str], filepath: Optional[PathOrFileW] = None, include_extract: bool = True, ) -> PathOrFileW: diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 6b29e736a..3ebebe28b 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,30 +1,42 @@ +from typing_extensions import Concatenate 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, Generic, List, Optional, TYPE_CHECKING, Tuple, TypeVar, Union +from typing import ( + Any, + Callable, + Dict, + Generic, + List, + Optional, + TYPE_CHECKING, + ParamSpec, + Tuple, + TypeVar, + Union, +) from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.request_options import RequestOptions -from .exceptions import ( +from tableauserverclient.server.endpoint.exceptions import ( ServerResponseError, InternalServerError, NonXMLResponseError, NotSignedInError, ) -from ..exceptions import EndpointUnavailableError +from tableauserverclient.server.exceptions import EndpointUnavailableError from tableauserverclient.server.query import QuerySet from tableauserverclient import helpers, get_versions from tableauserverclient.helpers.logging import logger -from tableauserverclient.config import DELAY_SLEEP_SECONDS if TYPE_CHECKING: - from ..server import Server + from tableauserverclient.server.server import Server from requests import Response @@ -38,7 +50,7 @@ USER_AGENT_HEADER = "User-Agent" -class Endpoint(object): +class Endpoint: def __init__(self, parent_srv: "Server"): self.parent_srv = parent_srv @@ -232,7 +244,12 @@ def patch_request(self, url, xml_request, content_type=XML_CONTENT_TYPE, paramet ) -def api(version): +E = TypeVar("E", bound="Endpoint") +P = ParamSpec("P") +R = TypeVar("R") + + +def api(version: str) -> Callable[[Callable[Concatenate[E, P], R]], Callable[Concatenate[E, P], R]]: """Annotate the minimum supported version for an endpoint. Checks the version on the server object and compares normalized versions. @@ -251,9 +268,9 @@ def api(version): >>> ... """ - def _decorator(func): + def _decorator(func: Callable[Concatenate[E, P], R]) -> Callable[Concatenate[E, P], R]: @wraps(func) - def wrapper(self, *args, **kwargs): + def wrapper(self: E, *args: P.args, **kwargs: P.kwargs) -> R: self.parent_srv.assert_at_least_version(version, self.__class__.__name__) return func(self, *args, **kwargs) @@ -262,7 +279,7 @@ def wrapper(self, *args, **kwargs): return _decorator -def parameter_added_in(**params): +def parameter_added_in(**params: str) -> Callable[[Callable[Concatenate[E, P], R]], Callable[Concatenate[E, P], R]]: """Annotate minimum versions for new parameters or request options on an endpoint. The api decorator documents when an endpoint was added, this decorator annotates @@ -285,9 +302,9 @@ def parameter_added_in(**params): >>> ... """ - def _decorator(func): + def _decorator(func: Callable[Concatenate[E, P], R]) -> Callable[Concatenate[E, P], R]: @wraps(func) - def wrapper(self, *args, **kwargs): + def wrapper(self: E, *args: P.args, **kwargs: P.kwargs) -> R: import warnings server_ver = Version(self.parent_srv.version or "0.0") @@ -335,5 +352,5 @@ def paginate(self, **kwargs) -> QuerySet[T]: return queryset @abc.abstractmethod - def get(self, request_options: RequestOptions) -> Tuple[List[T], PaginationItem]: + def get(self, request_options: Optional[RequestOptions] = None) -> Tuple[List[T], PaginationItem]: raise NotImplementedError(f".get has not been implemented for {self.__class__.__qualname__}") diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index a48a3244c..54e699722 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -1,4 +1,5 @@ import logging +from typing_extensions import Self, overload from tableauserverclient.server.query import QuerySet @@ -13,15 +14,25 @@ from typing import List, Optional, Tuple, Union -class Jobs(QuerysetEndpoint[JobItem]): +class Jobs(QuerysetEndpoint[BackgroundJobItem]): @property def baseurl(self): return "{0}/sites/{1}/jobs".format(self.parent_srv.baseurl, self.parent_srv.site_id) + @overload # type: ignore[override] + def get(self: Self, job_id: str, req_options: Optional[RequestOptionsBase] = None) -> JobItem: # type: ignore[override] + ... + + @overload # type: ignore[override] + def get(self: Self, job_id: RequestOptionsBase, req_options: None) -> Tuple[List[BackgroundJobItem], PaginationItem]: # type: ignore[override] + ... + + @overload # type: ignore[override] + def get(self: Self, job_id: None, req_options: Optional[RequestOptionsBase]) -> Tuple[List[BackgroundJobItem], PaginationItem]: # type: ignore[override] + ... + @api(version="2.6") - def get( - self, job_id: Optional[str] = None, req_options: Optional[RequestOptionsBase] = None - ) -> Tuple[List[BackgroundJobItem], PaginationItem]: + def get(self, job_id=None, req_options=None): # Backwards Compatibility fix until we rev the major version if job_id is not None and isinstance(job_id, str): import warnings @@ -77,7 +88,7 @@ def wait_for_job(self, job_id: Union[str, JobItem], *, timeout: Optional[float] else: raise AssertionError("Unexpected finish_code in job", job) - def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[JobItem]: + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[BackgroundJobItem]: """ Queries the Tableau Server for items using the specified filters. Page size can be specified to limit the number of items returned in a single diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 55f61370f..78a3b0858 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -184,7 +184,7 @@ def download( workbook_id: str, filepath: Optional[PathOrFileW] = None, include_extract: bool = True, - ) -> str: + ) -> PathOrFileW: return self.download_revision( workbook_id, None, From 8d6b3f24830eb9212c839ce0783d07e81bb0a8f3 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 23 Aug 2024 11:21:07 -0500 Subject: [PATCH 218/296] chore(typing): request factory ts_wrapped --- tableauserverclient/server/request_factory.py | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index aeb355ea6..04a1138a3 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1,8 +1,9 @@ import xml.etree.ElementTree as ET -from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Union, TYPE_CHECKING +from typing import Any, Callable, Dict, Iterable, List, Optional, ParamSpec, Set, Tuple, TypeVar, TYPE_CHECKING, Union from requests.packages.urllib3.fields import RequestField from requests.packages.urllib3.filepost import encode_multipart_formdata +from typing_extensions import Concatenate from tableauserverclient.models import * @@ -23,8 +24,12 @@ def _add_multipart(parts: Dict) -> Tuple[Any, str]: return xml_request, content_type -def _tsrequest_wrapped(func): - def wrapper(self, *args, **kwargs) -> bytes: +T = TypeVar("T") +P = ParamSpec("P") + + +def _tsrequest_wrapped(func: Callable[Concatenate[T, ET.Element, P], Any]) -> Callable[Concatenate[T, P], bytes]: + def wrapper(self: T, *args: P.args, **kwargs: P.kwargs) -> bytes: xml_request = ET.Element("tsRequest") func(self, xml_request, *args, **kwargs) return ET.tostring(xml_request) @@ -388,7 +393,7 @@ def add_user_req(self, user_id: str) -> bytes: return ET.tostring(xml_request) @_tsrequest_wrapped - def add_users_req(self, xml_request, users: Iterable[Union[str, UserItem]]) -> bytes: + def add_users_req(self, xml_request: ET.Element, users: Iterable[Union[str, UserItem]]) -> bytes: users_element = ET.SubElement(xml_request, "users") for user in users: user_element = ET.SubElement(users_element, "user") @@ -399,7 +404,7 @@ def add_users_req(self, xml_request, users: Iterable[Union[str, UserItem]]) -> b return ET.tostring(xml_request) @_tsrequest_wrapped - def remove_users_req(self, xml_request, users: Iterable[Union[str, UserItem]]) -> bytes: + def remove_users_req(self, xml_request: ET.Element, users: Iterable[Union[str, UserItem]]) -> bytes: users_element = ET.SubElement(xml_request, "users") for user in users: user_element = ET.SubElement(users_element, "user") @@ -1055,14 +1060,17 @@ def publish_req_chunked( return _add_multipart(parts) @_tsrequest_wrapped - def embedded_extract_req(self, xml_request, include_all=True, datasources=None): + def embedded_extract_req( + self, xml_request: ET.Element, include_all: bool = True, datasources: Optional[Iterable[DatasourceItem]] = None + ) -> None: list_element = ET.SubElement(xml_request, "datasources") if include_all: list_element.attrib["includeAll"] = "true" elif datasources: for datasource_item in datasources: datasource_element = ET.SubElement(list_element, "datasource") - datasource_element.attrib["id"] = datasource_item.id + if (id_ := datasource_item.id) is not None: + datasource_element.attrib["id"] = id_ class Connection(object): @@ -1090,7 +1098,7 @@ def update_req(self, xml_request: ET.Element, connection_item: "ConnectionItem") class TaskRequest(object): @_tsrequest_wrapped - def run_req(self, xml_request, task_item): + def run_req(self, xml_request: ET.Element, task_item: Any) -> None: # Send an empty tsRequest pass @@ -1227,7 +1235,7 @@ def update_req(self, xml_request: ET.Element, subscription_item: "SubscriptionIt class EmptyRequest(object): @_tsrequest_wrapped - def empty_req(self, xml_request): + def empty_req(self, xml_request: ET.Element) -> None: pass From 9f7bfaaf55054e47c0c75521cb1a335426cdaf62 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sat, 24 Aug 2024 07:31:30 -0500 Subject: [PATCH 219/296] chore: fix typing on TaggingMixin --- .../server/endpoint/datasources_endpoint.py | 2 +- .../server/endpoint/flows_endpoint.py | 2 +- .../server/endpoint/resource_tagger.py | 39 ++++++++++--------- .../server/endpoint/tables_endpoint.py | 2 +- .../server/endpoint/views_endpoint.py | 2 +- .../server/endpoint/workbooks_endpoint.py | 2 +- 6 files changed, 26 insertions(+), 23 deletions(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index caa591fac..7f3a47075 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -55,7 +55,7 @@ PathOrFileW = Union[FilePath, FileObjectW] -class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin): +class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]): def __init__(self, parent_srv: "Server") -> None: super(Datasources, self).__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 2adbe1f92..53d072f50 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -51,7 +51,7 @@ PathOrFileW = Union[FilePath, FileObjectW] -class Flows(QuerysetEndpoint[FlowItem], TaggingMixin): +class Flows(QuerysetEndpoint[FlowItem], TaggingMixin[FlowItem]): def __init__(self, parent_srv): super(Flows, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index f6b1cab05..1894e3b8a 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -1,6 +1,6 @@ import abc import copy -from typing import Iterable, Optional, Protocol, Set, Union, TYPE_CHECKING, runtime_checkable +from typing import Generic, Iterable, Optional, Protocol, Set, TypeVar, Union, TYPE_CHECKING, runtime_checkable import urllib.parse from tableauserverclient.server.endpoint.endpoint import Endpoint, api @@ -62,27 +62,24 @@ def update_tags(self, baseurl, resource_item): logger.info("Updated tags to {0}".format(resource_item.tags)) -class HasID(Protocol): - @property - def id(self) -> Optional[str]: - pass +class Response(Protocol): + content: bytes @runtime_checkable class Taggable(Protocol): - _initial_tags: Set[str] tags: Set[str] + _initial_tags: Set[str] @property def id(self) -> Optional[str]: pass -class Response(Protocol): - content: bytes +T = TypeVar("T") -class TaggingMixin(abc.ABC): +class TaggingMixin(abc.ABC, Generic[T]): parent_srv: "Server" @property @@ -98,7 +95,7 @@ def put_request(self, url, request) -> Response: def delete_request(self, url) -> None: pass - def add_tags(self, item: Union[HasID, Taggable, str], tags: Union[Iterable[str], str]) -> Set[str]: + def add_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> Set[str]: item_id = getattr(item, "id", item) if not isinstance(item_id, str): @@ -114,7 +111,7 @@ def add_tags(self, item: Union[HasID, Taggable, str], tags: Union[Iterable[str], server_response = self.put_request(url, add_req) return TagItem.from_response(server_response.content, self.parent_srv.namespace) - def delete_tags(self, item: Union[HasID, Taggable, str], tags: Union[Iterable[str], str]) -> None: + def delete_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> None: item_id = getattr(item, "id", item) if not isinstance(item_id, str): @@ -130,17 +127,23 @@ def delete_tags(self, item: Union[HasID, Taggable, str], tags: Union[Iterable[st url = f"{self.baseurl}/{item_id}/tags/{encoded_tag_name}" self.delete_request(url) - def update_tags(self, item: Taggable) -> None: - if item.tags == item._initial_tags: + def update_tags(self, item: T) -> None: + if (initial_tags := getattr(item, "_initial_tags", None)) is None: + raise ValueError(f"{item} does not have initial tags.") + if (tags := getattr(item, "tags", None)) is None: + raise ValueError(f"{item} does not have tags.") + if tags == initial_tags: return - add_set = item.tags - item._initial_tags - remove_set = item._initial_tags - item.tags + add_set = tags - initial_tags + remove_set = initial_tags - tags self.delete_tags(item, remove_set) if add_set: - item.tags = self.add_tags(item, add_set) - item._initial_tags = copy.copy(item.tags) - logger.info(f"Updated tags to {item.tags}") + tags = self.add_tags(item, add_set) + setattr(item, "tags", tags) + + setattr(item, "_initial_tags", copy.copy(tags)) + logger.info(f"Updated tags to {tags}") content = Iterable[Union["ColumnItem", "DatabaseItem", "DatasourceItem", "FlowItem", "TableItem", "WorkbookItem"]] diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index b2e41df8b..36ef78c0a 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -13,7 +13,7 @@ from tableauserverclient.helpers.logging import logger -class Tables(Endpoint, TaggingMixin): +class Tables(Endpoint, TaggingMixin[TableItem]): def __init__(self, parent_srv): super(Tables, self).__init__(parent_srv) diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 7a8623614..f2ccf658e 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -23,7 +23,7 @@ ) -class Views(QuerysetEndpoint[ViewItem], TaggingMixin): +class Views(QuerysetEndpoint[ViewItem], TaggingMixin[ViewItem]): def __init__(self, parent_srv): super(Views, self).__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 78a3b0858..da6eda3de 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -59,7 +59,7 @@ PathOrFileW = Union[FilePath, FileObjectW] -class Workbooks(QuerysetEndpoint[WorkbookItem], TaggingMixin): +class Workbooks(QuerysetEndpoint[WorkbookItem], TaggingMixin[WorkbookItem]): def __init__(self, parent_srv: "Server") -> None: super(Workbooks, self).__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) From 01b171efcb6841d4c0e7e7940016bd6d953fe412 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sat, 24 Aug 2024 12:19:07 -0500 Subject: [PATCH 220/296] test: update_tags --- test/test_tagging.py | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/test/test_tagging.py b/test/test_tagging.py index d3f23d40e..7621ce6ed 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack import re from typing import Iterable import uuid @@ -6,7 +7,6 @@ import pytest import requests_mock import tableauserverclient as TSC -from tableauserverclient.server.endpoint.resource_tagger import content @pytest.fixture @@ -153,6 +153,42 @@ def test_delete_tags(get_server, endpoint_type, item, tags) -> None: urls = {r.url.split("/")[-1] for r in history} assert urls == tag_set +@pytest.mark.parametrize("endpoint_type, item", *sample_taggable_items) +@pytest.mark.parametrize("tags", sample_tags) +def test_update_tags(get_server, endpoint_type, item, tags) -> None: + if isinstance(item, str): + return + endpoint = getattr(get_server, endpoint_type) + id_ = getattr(item, "id", item) + tags = set([tags] if isinstance(tags, str) else tags) + with ExitStack() as stack: + if hasattr(item, "_initial_tags"): + initial_tags = set(['x','y','z']) + item._initial_tags = initial_tags + add_tags_xml = add_tag_xml_response_factory(tags - initial_tags) + delete_tags_xml = add_tag_xml_response_factory(initial_tags - tags) + m = stack.enter_context(requests_mock.mock()) + m.put( + f"{endpoint.baseurl}/{id_}/tags", + status_code=200, + text=add_tags_xml, + ) + + tag_paths = "|".join(initial_tags - tags) + tag_paths = f"({tag_paths})" + matcher = re.compile(rf"{endpoint.baseurl}\/{id_}\/tags\/{tag_paths}") + m.delete( + matcher, + status_code=200, + text=delete_tags_xml, + ) + + else: + stack.enter_context(pytest.raises(NotImplementedError)) + + + endpoint.update_tags(item) + def test_tags_batch_add(get_server) -> None: server = get_server From 99d330f7bbb8079b8de65d665015d815e470612d Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sat, 24 Aug 2024 12:20:00 -0500 Subject: [PATCH 221/296] fix: ParamSpec introduced in 3.10 --- tableauserverclient/server/endpoint/endpoint.py | 3 +-- tableauserverclient/server/request_factory.py | 4 +++- test/test_tagging.py | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 3ebebe28b..0e55d5739 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,4 +1,4 @@ -from typing_extensions import Concatenate +from typing_extensions import Concatenate, ParamSpec from tableauserverclient import datetime_helpers as datetime import abc @@ -13,7 +13,6 @@ List, Optional, TYPE_CHECKING, - ParamSpec, Tuple, TypeVar, Union, diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 04a1138a3..7fc9c9555 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1,5 +1,7 @@ import xml.etree.ElementTree as ET -from typing import Any, Callable, Dict, Iterable, List, Optional, ParamSpec, Set, Tuple, TypeVar, TYPE_CHECKING, Union +from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, TypeVar, TYPE_CHECKING, Union + +from typing_extensions import ParamSpec from requests.packages.urllib3.fields import RequestField from requests.packages.urllib3.filepost import encode_multipart_formdata diff --git a/test/test_tagging.py b/test/test_tagging.py index 7621ce6ed..2ec2b8112 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -153,6 +153,7 @@ def test_delete_tags(get_server, endpoint_type, item, tags) -> None: urls = {r.url.split("/")[-1] for r in history} assert urls == tag_set + @pytest.mark.parametrize("endpoint_type, item", *sample_taggable_items) @pytest.mark.parametrize("tags", sample_tags) def test_update_tags(get_server, endpoint_type, item, tags) -> None: @@ -163,7 +164,7 @@ def test_update_tags(get_server, endpoint_type, item, tags) -> None: tags = set([tags] if isinstance(tags, str) else tags) with ExitStack() as stack: if hasattr(item, "_initial_tags"): - initial_tags = set(['x','y','z']) + initial_tags = set(["x", "y", "z"]) item._initial_tags = initial_tags add_tags_xml = add_tag_xml_response_factory(tags - initial_tags) delete_tags_xml = add_tag_xml_response_factory(initial_tags - tags) @@ -186,7 +187,6 @@ def test_update_tags(get_server, endpoint_type, item, tags) -> None: else: stack.enter_context(pytest.raises(NotImplementedError)) - endpoint.update_tags(item) From cc4e47a55208d37c9dadd30dfc8cf2afb4f6582e Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sat, 24 Aug 2024 12:45:21 -0500 Subject: [PATCH 222/296] test: update_tags item doesn't have tags attributes --- test/test_tagging.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/test_tagging.py b/test/test_tagging.py index 2ec2b8112..fc88eea8a 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -157,13 +157,13 @@ def test_delete_tags(get_server, endpoint_type, item, tags) -> None: @pytest.mark.parametrize("endpoint_type, item", *sample_taggable_items) @pytest.mark.parametrize("tags", sample_tags) def test_update_tags(get_server, endpoint_type, item, tags) -> None: - if isinstance(item, str): - return endpoint = getattr(get_server, endpoint_type) id_ = getattr(item, "id", item) tags = set([tags] if isinstance(tags, str) else tags) with ExitStack() as stack: - if hasattr(item, "_initial_tags"): + if isinstance(item, str): + stack.enter_context(pytest.raises((ValueError, NotImplementedError))) + elif hasattr(item, "_initial_tags"): initial_tags = set(["x", "y", "z"]) item._initial_tags = initial_tags add_tags_xml = add_tag_xml_response_factory(tags - initial_tags) From 51112f1e6d91fea450882a86457af3016a3f3ec2 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 11 Jul 2024 21:11:06 -0500 Subject: [PATCH 223/296] fix: str/repr for BackgroundJobItem --- tableauserverclient/models/job_item.py | 2 +- test/test_job.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 9933d7f29..12d025bef 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -222,7 +222,7 @@ def __init__( self._subtitle = subtitle def __str__(self): - return f"<{self.__class__.name} {self._id} {self._type}>" + return f"<{self.__class__.__qualname__} {self._id} {self._type}>" def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" diff --git a/test/test_job.py b/test/test_job.py index 8e248882c..d86397086 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -136,3 +136,11 @@ def test_get_job_datasource_name(self) -> None: m.get(f"{self.baseurl}/{job_id}", text=response_xml) job = self.server.jobs.get_by_id(job_id) self.assertEqual(job.datasource_name, "World Indicators") + + def test_background_job_str(self) -> None: + job = TSC.BackgroundJobItem( + "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", datetime.now(), 1, "extractRefresh", "Failed" + ) + assert not str(job).startswith("< Date: Thu, 11 Jul 2024 21:24:23 -0500 Subject: [PATCH 224/296] chore: jobs_endpoint absolute imports --- tableauserverclient/server/endpoint/jobs_endpoint.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index a48a3244c..b9ac24924 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -1,11 +1,11 @@ import logging -from tableauserverclient.server.query import QuerySet -from .endpoint import QuerysetEndpoint, api -from .exceptions import JobCancelledException, JobFailedException from tableauserverclient.models import JobItem, BackgroundJobItem, PaginationItem -from ..request_options import RequestOptionsBase +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api +from tableauserverclient.server.endpoint.exceptions import JobCancelledException, JobFailedException +from tableauserverclient.server.query import QuerySet +from tableauserverclient.server.request_options import RequestOptionsBase from tableauserverclient.exponential_backoff import ExponentialBackoffTimer from tableauserverclient.helpers.logging import logger From 3c3c5b30d8c6b89793bd3a5c69fd67c16c4ebeb0 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 11 Jul 2024 21:34:32 -0500 Subject: [PATCH 225/296] chore: absolute imports for jobitem --- tableauserverclient/models/job_item.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 12d025bef..155ce668b 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -4,7 +4,7 @@ from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime -from .flow_run_item import FlowRunItem +from tableauserverclient.models.flow_run_item import FlowRunItem class JobItem(object): From 7dc5ad4ae36a1609a8f2374aafe06b2598fa9926 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Mon, 26 Aug 2024 22:32:56 -0500 Subject: [PATCH 226/296] fix: Pager typing Pager Protocols were missing the generic flags. Added those in so the Pager correctly passes through the typing information. Also removes the kwargs from the function signature of the Endpoint.get protocol to make the Workbook endpoint match. Adding a return annotation of `None` to the tests is very important because it is what enables static type checkers, like mypy, to inspect those functions. With these annotations now in place, users of TSC should more transparently be able to carry through typing information when using the Pager. --- tableauserverclient/server/pager.py | 14 +++++++------- test/test_pager.py | 17 +++++++++++++---- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index fede56012..cb6dd2d75 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -1,24 +1,24 @@ +from collections.abc import Iterable, Iterator import copy from functools import partial -from typing import Generic, Iterable, Iterator, List, Optional, Protocol, Tuple, TypeVar, Union, runtime_checkable +from typing import List, Optional, Protocol, Tuple, TypeVar, Union, runtime_checkable from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.request_options import RequestOptions T = TypeVar("T") -ReturnType = Tuple[List[T], PaginationItem] @runtime_checkable -class Endpoint(Protocol): - def get(self, req_options: Optional[RequestOptions], **kwargs) -> ReturnType: +class Endpoint(Protocol[T]): + def get(self, req_options: Optional[RequestOptions]) -> Tuple[List[T], PaginationItem]: ... @runtime_checkable -class CallableEndpoint(Protocol): - def __call__(self, __req_options: Optional[RequestOptions], **kwargs) -> ReturnType: +class CallableEndpoint(Protocol[T]): + def __call__(self, __req_options: Optional[RequestOptions], **kwargs) -> Tuple[List[T], PaginationItem]: ... @@ -33,7 +33,7 @@ class Pager(Iterable[T]): def __init__( self, - endpoint: Union[CallableEndpoint, Endpoint], + endpoint: Union[CallableEndpoint[T], Endpoint[T]], request_opts: Optional[RequestOptions] = None, **kwargs, ) -> None: diff --git a/test/test_pager.py b/test/test_pager.py index 7659f2725..e548ace2b 100644 --- a/test/test_pager.py +++ b/test/test_pager.py @@ -9,6 +9,7 @@ TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") +GET_VIEW_XML = os.path.join(TEST_ASSET_DIR, "view_get.xml") GET_XML_PAGE1 = os.path.join(TEST_ASSET_DIR, "workbook_get_page_1.xml") GET_XML_PAGE2 = os.path.join(TEST_ASSET_DIR, "workbook_get_page_2.xml") GET_XML_PAGE3 = os.path.join(TEST_ASSET_DIR, "workbook_get_page_3.xml") @@ -35,7 +36,7 @@ def setUp(self): self.baseurl = self.server.workbooks.baseurl - def test_pager_with_no_options(self): + def test_pager_with_no_options(self) -> None: with open(GET_XML_PAGE1, "rb") as f: page_1 = f.read().decode("utf-8") with open(GET_XML_PAGE2, "rb") as f: @@ -61,7 +62,7 @@ def test_pager_with_no_options(self): self.assertEqual(wb2.name, "Page2Workbook") self.assertEqual(wb3.name, "Page3Workbook") - def test_pager_with_options(self): + def test_pager_with_options(self) -> None: with open(GET_XML_PAGE1, "rb") as f: page_1 = f.read().decode("utf-8") with open(GET_XML_PAGE2, "rb") as f: @@ -102,14 +103,22 @@ def test_pager_with_options(self): wb3 = workbooks.pop() self.assertEqual(wb3.name, "Page3Workbook") - def test_pager_with_env_var(self): + def test_pager_with_env_var(self) -> None: with set_env(TSC_PAGE_SIZE="1000"): assert config.PAGE_SIZE == 1000 loop = TSC.Pager(self.server.workbooks) assert loop._options.pagesize == 1000 - def test_queryset_with_env_var(self): + def test_queryset_with_env_var(self) -> None: with set_env(TSC_PAGE_SIZE="1000"): assert config.PAGE_SIZE == 1000 loop = self.server.workbooks.all() assert loop.request_options.pagesize == 1000 + + def test_pager_view(self) -> None: + with open(GET_VIEW_XML, "rb") as f: + view_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get(self.baseurl, text=view_xml) + for view in TSC.Pager(self.server.views): + assert view.name == "Test View" From a7b5e2c07bff436ea7ca00d63063569fab9a1bdd Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Mon, 26 Aug 2024 22:41:18 -0500 Subject: [PATCH 227/296] fix: python3.8 syntax --- tableauserverclient/server/pager.py | 3 +-- test/test_pager.py | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index cb6dd2d75..ca9d83872 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -1,7 +1,6 @@ -from collections.abc import Iterable, Iterator import copy from functools import partial -from typing import List, Optional, Protocol, Tuple, TypeVar, Union, runtime_checkable +from typing import Iterable, Iterator, List, Optional, Protocol, Tuple, TypeVar, Union, runtime_checkable from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.request_options import RequestOptions diff --git a/test/test_pager.py b/test/test_pager.py index e548ace2b..c30352809 100644 --- a/test/test_pager.py +++ b/test/test_pager.py @@ -119,6 +119,6 @@ def test_pager_view(self) -> None: with open(GET_VIEW_XML, "rb") as f: view_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl, text=view_xml) + m.get(self.server.views.baseurl, text=view_xml) for view in TSC.Pager(self.server.views): - assert view.name == "Test View" + assert view.name is not None From fad98bd4fca42d22965d855abb8ae0454b9e0a80 Mon Sep 17 00:00:00 2001 From: "jac.fitzgerald" Date: Mon, 2 Sep 2024 12:31:33 -0700 Subject: [PATCH 228/296] feat: virtual connections Merge pull request #1429 from jorwoods/jorwoods/virtual_connections --- tableauserverclient/__init__.py | 2 + tableauserverclient/models/__init__.py | 2 + tableauserverclient/models/connection_item.py | 6 +- tableauserverclient/models/tableau_types.py | 16 +- .../models/virtual_connection_item.py | 77 ++++++ .../server/endpoint/__init__.py | 2 + .../endpoint/virtual_connections_endpoint.py | 173 +++++++++++++ tableauserverclient/server/request_factory.py | 60 +++++ tableauserverclient/server/server.py | 2 + .../virtual_connection_add_permissions.xml | 21 ++ ..._connection_database_connection_update.xml | 6 + ...irtual_connection_populate_connections.xml | 6 + test/assets/virtual_connections_download.xml | 7 + test/assets/virtual_connections_get.xml | 14 + test/assets/virtual_connections_publish.xml | 7 + test/assets/virtual_connections_revisions.xml | 14 + test/assets/virtual_connections_update.xml | 8 + test/test_tagging.py | 8 + test/test_virtual_connection.py | 242 ++++++++++++++++++ 19 files changed, 664 insertions(+), 9 deletions(-) create mode 100644 tableauserverclient/models/virtual_connection_item.py create mode 100644 tableauserverclient/server/endpoint/virtual_connections_endpoint.py create mode 100644 test/assets/virtual_connection_add_permissions.xml create mode 100644 test/assets/virtual_connection_database_connection_update.xml create mode 100644 test/assets/virtual_connection_populate_connections.xml create mode 100644 test/assets/virtual_connections_download.xml create mode 100644 test/assets/virtual_connections_get.xml create mode 100644 test/assets/virtual_connections_publish.xml create mode 100644 test/assets/virtual_connections_revisions.xml create mode 100644 test/assets/virtual_connections_update.xml create mode 100644 test/test_virtual_connection.py diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index f8549992f..bab2cf05f 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -43,6 +43,7 @@ TaskItem, UserItem, ViewItem, + VirtualConnectionItem, WebhookItem, WeeklyInterval, WorkbookItem, @@ -124,4 +125,5 @@ "LinkedTaskItem", "LinkedTaskStepItem", "LinkedTaskFlowRunItem", + "VirtualConnectionItem", ] diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 41676da2c..e4131b720 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -45,6 +45,7 @@ from tableauserverclient.models.task_item import TaskItem from tableauserverclient.models.user_item import UserItem from tableauserverclient.models.view_item import ViewItem +from tableauserverclient.models.virtual_connection_item import VirtualConnectionItem from tableauserverclient.models.webhook_item import WebhookItem from tableauserverclient.models.workbook_item import WorkbookItem @@ -96,6 +97,7 @@ "TaskItem", "UserItem", "ViewItem", + "VirtualConnectionItem", "WebhookItem", "WorkbookItem", "LinkedTaskItem", diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 29ffd2700..62ff530c9 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -66,12 +66,14 @@ def from_response(cls, resp, ns) -> List["ConnectionItem"]: for connection_xml in all_connection_xml: connection_item = cls() connection_item._id = connection_xml.get("id", None) - connection_item._connection_type = connection_xml.get("type", None) + connection_item._connection_type = connection_xml.get("type", connection_xml.get("dbClass", None)) connection_item.embed_password = string_to_bool(connection_xml.get("embedPassword", "")) connection_item.server_address = connection_xml.get("serverAddress", None) connection_item.server_port = connection_xml.get("serverPort", None) connection_item.username = connection_xml.get("userName", None) - connection_item._query_tagging = string_to_bool(connection_xml.get("queryTaggingEnabled", None)) + connection_item._query_tagging = ( + string_to_bool(s) if (s := connection_xml.get("queryTagging", None)) else None + ) datasource_elem = connection_xml.find(".//t:datasource", namespaces=ns) if datasource_elem is not None: connection_item._datasource_id = datasource_elem.get("id", None) diff --git a/tableauserverclient/models/tableau_types.py b/tableauserverclient/models/tableau_types.py index 33fe5eb0c..bac072076 100644 --- a/tableauserverclient/models/tableau_types.py +++ b/tableauserverclient/models/tableau_types.py @@ -1,11 +1,12 @@ from typing import Union -from .datasource_item import DatasourceItem -from .flow_item import FlowItem -from .project_item import ProjectItem -from .view_item import ViewItem -from .workbook_item import WorkbookItem -from .metric_item import MetricItem +from tableauserverclient.models.datasource_item import DatasourceItem +from tableauserverclient.models.flow_item import FlowItem +from tableauserverclient.models.project_item import ProjectItem +from tableauserverclient.models.view_item import ViewItem +from tableauserverclient.models.workbook_item import WorkbookItem +from tableauserverclient.models.metric_item import MetricItem +from tableauserverclient.models.virtual_connection_item import VirtualConnectionItem class Resource: @@ -18,12 +19,13 @@ class Resource: Metric = "metric" Project = "project" View = "view" + VirtualConnection = "virtualConnection" Workbook = "workbook" # resource types that have permissions, can be renamed, etc # todo: refactoring: should actually define TableauItem as an interface and let all these implement it -TableauItem = Union[DatasourceItem, FlowItem, MetricItem, ProjectItem, ViewItem, WorkbookItem] +TableauItem = Union[DatasourceItem, FlowItem, MetricItem, ProjectItem, ViewItem, WorkbookItem, VirtualConnectionItem] def plural_type(content_type: Resource) -> str: diff --git a/tableauserverclient/models/virtual_connection_item.py b/tableauserverclient/models/virtual_connection_item.py new file mode 100644 index 000000000..76a3b5dea --- /dev/null +++ b/tableauserverclient/models/virtual_connection_item.py @@ -0,0 +1,77 @@ +import datetime as dt +import json +from typing import Callable, Dict, Iterable, List, Optional +from xml.etree.ElementTree import Element + +from defusedxml.ElementTree import fromstring + +from tableauserverclient.datetime_helpers import parse_datetime +from tableauserverclient.models.connection_item import ConnectionItem +from tableauserverclient.models.exceptions import UnpopulatedPropertyError +from tableauserverclient.models.permissions_item import PermissionsRule + + +class VirtualConnectionItem: + def __init__(self, name: str) -> None: + self.name = name + self.created_at: Optional[dt.datetime] = None + self.has_extracts: Optional[bool] = None + self._id: Optional[str] = None + self.is_certified: Optional[bool] = None + self.updated_at: Optional[dt.datetime] = None + self.webpage_url: Optional[str] = None + self._connections: Optional[Callable[[], Iterable[ConnectionItem]]] = None + self.project_id: Optional[str] = None + self.owner_id: Optional[str] = None + self.content: Optional[Dict[str, dict]] = None + self.certification_note: Optional[str] = None + + def __str__(self) -> str: + return f"{self.__class__.__qualname__}(name={self.name})" + + def __repr__(self) -> str: + return f"<{self!s}>" + + def _set_permissions(self, permissions): + self._permissions = permissions + + @property + def id(self) -> Optional[str]: + return self._id + + @property + def permissions(self) -> List[PermissionsRule]: + if self._permissions is None: + error = "Workbook item must be populated with permissions first." + raise UnpopulatedPropertyError(error) + return self._permissions() + + @property + def connections(self) -> Iterable[ConnectionItem]: + if self._connections is None: + raise AttributeError("connections not populated. Call populate_connections() first.") + return self._connections() + + @classmethod + def from_response(cls, response: bytes, ns: Dict[str, str]) -> List["VirtualConnectionItem"]: + parsed_response = fromstring(response) + return [cls.from_xml(xml, ns) for xml in parsed_response.findall(".//t:virtualConnection[@name]", ns)] + + @classmethod + def from_xml(cls, xml: Element, ns: Dict[str, str]) -> "VirtualConnectionItem": + v_conn = cls(xml.get("name", "")) + v_conn._id = xml.get("id", None) + v_conn.webpage_url = xml.get("webpageUrl", None) + v_conn.created_at = parse_datetime(xml.get("createdAt", None)) + v_conn.updated_at = parse_datetime(xml.get("updatedAt", None)) + v_conn.is_certified = string_to_bool(s) if (s := xml.get("isCertified", None)) else None + v_conn.certification_note = xml.get("certificationNote", None) + v_conn.has_extracts = string_to_bool(s) if (s := xml.get("hasExtracts", None)) else None + v_conn.project_id = p.get("id", None) if ((p := xml.find(".//t:project[@id]", ns)) is not None) else None + v_conn.owner_id = o.get("id", None) if ((o := xml.find(".//t:owner[@id]", ns)) is not None) else None + v_conn.content = json.loads(c.text or "{}") if ((c := xml.find(".//t:content", ns)) is not None) else None + return v_conn + + +def string_to_bool(s: str) -> bool: + return s.lower() in ["true", "1", "t", "y", "yes"] diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index 30eae9224..b05b9addd 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -27,6 +27,7 @@ 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.virtual_connections_endpoint import VirtualConnections from tableauserverclient.server.endpoint.webhooks_endpoint import Webhooks from tableauserverclient.server.endpoint.workbooks_endpoint import Workbooks @@ -62,6 +63,7 @@ "Tasks", "Users", "Views", + "VirtualConnections", "Webhooks", "Workbooks", ] diff --git a/tableauserverclient/server/endpoint/virtual_connections_endpoint.py b/tableauserverclient/server/endpoint/virtual_connections_endpoint.py new file mode 100644 index 000000000..f71db00cc --- /dev/null +++ b/tableauserverclient/server/endpoint/virtual_connections_endpoint.py @@ -0,0 +1,173 @@ +from functools import partial +import json +from pathlib import Path +from typing import Iterable, List, Optional, Set, TYPE_CHECKING, Tuple, Union + +from tableauserverclient.models.connection_item import ConnectionItem +from tableauserverclient.models.pagination_item import PaginationItem +from tableauserverclient.models.revision_item import RevisionItem +from tableauserverclient.models.virtual_connection_item import VirtualConnectionItem +from tableauserverclient.server.request_factory import RequestFactory +from tableauserverclient.server.request_options import RequestOptions +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api +from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint +from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin +from tableauserverclient.server.pager import Pager + +if TYPE_CHECKING: + from tableauserverclient.server import Server + + +class VirtualConnections(QuerysetEndpoint[VirtualConnectionItem], TaggingMixin): + def __init__(self, parent_srv: "Server") -> None: + super().__init__(parent_srv) + self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) + + @property + def baseurl(self) -> str: + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/virtualConnections" + + @api(version="3.18") + def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[VirtualConnectionItem], PaginationItem]: + server_response = self.get_request(self.baseurl, req_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + virtual_connections = VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace) + return virtual_connections, pagination_item + + @api(version="3.18") + def populate_connections(self, virtual_connection: VirtualConnectionItem) -> VirtualConnectionItem: + def _connection_fetcher(): + return Pager(partial(self._get_virtual_database_connections, virtual_connection)) + + virtual_connection._connections = _connection_fetcher + return virtual_connection + + def _get_virtual_database_connections( + self, virtual_connection: VirtualConnectionItem, req_options: Optional[RequestOptions] = None + ) -> Tuple[List[ConnectionItem], PaginationItem]: + server_response = self.get_request(f"{self.baseurl}/{virtual_connection.id}/connections", req_options) + connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + + return connections, pagination_item + + @api(version="3.18") + def update_connection_db_connection( + self, virtual_connection: Union[str, VirtualConnectionItem], connection: ConnectionItem + ) -> ConnectionItem: + vconn_id = getattr(virtual_connection, "id", virtual_connection) + url = f"{self.baseurl}/{vconn_id}/connections/{connection.id}/modify" + xml_request = RequestFactory.VirtualConnection.update_db_connection(connection) + server_response = self.put_request(url, xml_request) + return ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] + + @api(version="3.23") + def get_by_id(self, virtual_connection: Union[str, VirtualConnectionItem]) -> VirtualConnectionItem: + vconn_id = getattr(virtual_connection, "id", virtual_connection) + url = f"{self.baseurl}/{vconn_id}" + server_response = self.get_request(url) + return VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] + + @api(version="3.23") + def download(self, virtual_connection: Union[str, VirtualConnectionItem]) -> str: + v_conn = self.get_by_id(virtual_connection) + return json.dumps(v_conn.content) + + @api(version="3.23") + def update(self, virtual_connection: VirtualConnectionItem) -> VirtualConnectionItem: + url = f"{self.baseurl}/{virtual_connection.id}" + xml_request = RequestFactory.VirtualConnection.update(virtual_connection) + server_response = self.put_request(url, xml_request) + return VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] + + @api(version="3.23") + def get_revisions( + self, virtual_connection: VirtualConnectionItem, req_options: Optional[RequestOptions] = None + ) -> Tuple[List[RevisionItem], PaginationItem]: + server_response = self.get_request(f"{self.baseurl}/{virtual_connection.id}/revisions", req_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, virtual_connection) + return revisions, pagination_item + + @api(version="3.23") + def download_revision(self, virtual_connection: VirtualConnectionItem, revision_number: int) -> str: + url = f"{self.baseurl}/{virtual_connection.id}/revisions/{revision_number}" + server_response = self.get_request(url) + virtual_connection = VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] + return json.dumps(virtual_connection.content) + + @api(version="3.23") + def delete(self, virtual_connection: Union[VirtualConnectionItem, str]) -> None: + vconn_id = getattr(virtual_connection, "id", virtual_connection) + self.delete_request(f"{self.baseurl}/{vconn_id}") + + @api(version="3.23") + def publish( + self, + virtual_connection: VirtualConnectionItem, + virtual_connection_content: str, + mode: str = "CreateNew", + publish_as_draft: bool = False, + ) -> VirtualConnectionItem: + """ + Publish a virtual connection to the server. + + For the virtual_connection object, name, project_id, and owner_id are + required. + + The virtual_connection_content can be a json string or a file path to a + json file. + + The mode can be "CreateNew" or "Overwrite". If mode is + "Overwrite" and the virtual connection already exists, it will be + overwritten. + + If publish_as_draft is True, the virtual connection will be published + as a draft, and the id of the draft will be on the response object. + """ + try: + json.loads(virtual_connection_content) + except json.JSONDecodeError: + file = Path(virtual_connection_content) + if not file.exists(): + raise RuntimeError(f"{virtual_connection_content} is not valid json nor an existing file path") + content = file.read_text() + else: + content = virtual_connection_content + + if mode not in ["CreateNew", "Overwrite"]: + raise ValueError(f"Invalid mode: {mode}") + overwrite = mode == "Overwrite" + + url = f"{self.baseurl}?overwrite={str(overwrite).lower()}&publishAsDraft={str(publish_as_draft).lower()}" + xml_request = RequestFactory.VirtualConnection.publish(virtual_connection, content) + server_response = self.post_request(url, xml_request) + return VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] + + @api(version="3.22") + def populate_permissions(self, item: VirtualConnectionItem) -> None: + self._permissions.populate(item) + + @api(version="3.22") + def add_permissions(self, resource, rules): + return self._permissions.update(resource, rules) + + @api(version="3.22") + def delete_permission(self, item, capability_item): + return self._permissions.delete(item, capability_item) + + @api(version="3.23") + def add_tags( + self, virtual_connection: Union[VirtualConnectionItem, str], tags: Union[Iterable[str], str] + ) -> Set[str]: + return super().add_tags(virtual_connection, tags) + + @api(version="3.23") + def delete_tags( + self, virtual_connection: Union[VirtualConnectionItem, str], tags: Union[Iterable[str], str] + ) -> None: + return super().delete_tags(virtual_connection, tags) + + @api(version="3.23") + def update_tags(self, virtual_connection: VirtualConnectionItem) -> None: + raise NotImplementedError("Update tags is not implemented for Virtual Connections") diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 7fc9c9555..96fa14680 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1356,6 +1356,65 @@ def update_request(self, xml_request: ET.Element, group_set_item: "GroupSetItem" return ET.tostring(xml_request) +class VirtualConnectionRequest: + @_tsrequest_wrapped + def update_db_connection(self, xml_request: ET.Element, connection_item: ConnectionItem) -> bytes: + connection_element = ET.SubElement(xml_request, "connection") + if connection_item.server_address is not None: + connection_element.attrib["serverAddress"] = connection_item.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: + connection_element.attrib["userName"] = connection_item.username + if connection_item.password is not None: + connection_element.attrib["password"] = connection_item.password + + return ET.tostring(xml_request) + + @_tsrequest_wrapped + def update(self, xml_request: ET.Element, virtual_connection: VirtualConnectionItem) -> bytes: + vc_element = ET.SubElement(xml_request, "virtualConnection") + if virtual_connection.name is not None: + vc_element.attrib["name"] = virtual_connection.name + if virtual_connection.is_certified is not None: + vc_element.attrib["isCertified"] = str(virtual_connection.is_certified).lower() + if virtual_connection.certification_note is not None: + vc_element.attrib["certificationNote"] = virtual_connection.certification_note + if virtual_connection.project_id is not None: + project_element = ET.SubElement(vc_element, "project") + project_element.attrib["id"] = virtual_connection.project_id + if virtual_connection.owner_id is not None: + owner_element = ET.SubElement(vc_element, "owner") + owner_element.attrib["id"] = virtual_connection.owner_id + + return ET.tostring(xml_request) + + @_tsrequest_wrapped + def publish(self, xml_request: ET.Element, virtual_connection: VirtualConnectionItem, content: str) -> bytes: + vc_element = ET.SubElement(xml_request, "virtualConnection") + if virtual_connection.name is not None: + vc_element.attrib["name"] = virtual_connection.name + else: + raise ValueError("Virtual Connection must have a name.") + if virtual_connection.project_id is not None: + project_element = ET.SubElement(vc_element, "project") + project_element.attrib["id"] = virtual_connection.project_id + else: + raise ValueError("Virtual Connection must have a project id.") + if virtual_connection.owner_id is not None: + owner_element = ET.SubElement(vc_element, "owner") + owner_element.attrib["id"] = virtual_connection.owner_id + else: + raise ValueError("Virtual Connection must have an owner id.") + if content is not None: + content_element = ET.SubElement(vc_element, "content") + content_element.text = content + else: + raise ValueError("Virtual Connection must have content.") + + return ET.tostring(xml_request) + + class RequestFactory(object): Auth = AuthRequest() Connection = Connection() @@ -1382,5 +1441,6 @@ class RequestFactory(object): Tag = TagRequest() Task = TaskRequest() User = UserRequest() + VirtualConnection = VirtualConnectionRequest() Workbook = WorkbookRequest() Webhook = WebhookRequest() diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 20a7dc3df..e563a7138 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -36,6 +36,7 @@ LinkedTasks, GroupSets, Tags, + VirtualConnections, ) from tableauserverclient.server.exceptions import ( ServerInfoEndpointNotFoundError, @@ -105,6 +106,7 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self.linked_tasks = LinkedTasks(self) self.group_sets = GroupSets(self) self.tags = Tags(self) + self.virtual_connections = VirtualConnections(self) self._session = self._session_factory() self._http_options = dict() # must set this before making a server call diff --git a/test/assets/virtual_connection_add_permissions.xml b/test/assets/virtual_connection_add_permissions.xml new file mode 100644 index 000000000..d8b052848 --- /dev/null +++ b/test/assets/virtual_connection_add_permissions.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/test/assets/virtual_connection_database_connection_update.xml b/test/assets/virtual_connection_database_connection_update.xml new file mode 100644 index 000000000..a6135d604 --- /dev/null +++ b/test/assets/virtual_connection_database_connection_update.xml @@ -0,0 +1,6 @@ + + + + diff --git a/test/assets/virtual_connection_populate_connections.xml b/test/assets/virtual_connection_populate_connections.xml new file mode 100644 index 000000000..77d899520 --- /dev/null +++ b/test/assets/virtual_connection_populate_connections.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/assets/virtual_connections_download.xml b/test/assets/virtual_connections_download.xml new file mode 100644 index 000000000..889e70ce7 --- /dev/null +++ b/test/assets/virtual_connections_download.xml @@ -0,0 +1,7 @@ + + + + + {"policyCollection":{"luid":"34ae5eb9-ceac-4158-86f1-a5d8163d5261","policies":[]},"revision":{"luid":"1b2e2aae-b904-4f5a-aa4d-9f114b8e5f57","revisableProperties":{}}} + + diff --git a/test/assets/virtual_connections_get.xml b/test/assets/virtual_connections_get.xml new file mode 100644 index 000000000..f1f410e4c --- /dev/null +++ b/test/assets/virtual_connections_get.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/test/assets/virtual_connections_publish.xml b/test/assets/virtual_connections_publish.xml new file mode 100644 index 000000000..889e70ce7 --- /dev/null +++ b/test/assets/virtual_connections_publish.xml @@ -0,0 +1,7 @@ + + + + + {"policyCollection":{"luid":"34ae5eb9-ceac-4158-86f1-a5d8163d5261","policies":[]},"revision":{"luid":"1b2e2aae-b904-4f5a-aa4d-9f114b8e5f57","revisableProperties":{}}} + + diff --git a/test/assets/virtual_connections_revisions.xml b/test/assets/virtual_connections_revisions.xml new file mode 100644 index 000000000..374113427 --- /dev/null +++ b/test/assets/virtual_connections_revisions.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/test/assets/virtual_connections_update.xml b/test/assets/virtual_connections_update.xml new file mode 100644 index 000000000..60d5d1697 --- /dev/null +++ b/test/assets/virtual_connections_update.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/test/test_tagging.py b/test/test_tagging.py index fc88eea8a..0184af415 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -83,6 +83,12 @@ def make_flow() -> TSC.FlowItem: return flow +def make_vconn() -> TSC.VirtualConnectionItem: + vconn = TSC.VirtualConnectionItem("test") + vconn._id = str(uuid.uuid4()) + return vconn + + sample_taggable_items = ( [ ("workbooks", make_workbook()), @@ -97,6 +103,8 @@ def make_flow() -> TSC.FlowItem: ("databases", "some_id"), ("flows", make_flow()), ("flows", "some_id"), + ("virtual_connections", make_vconn()), + ("virtual_connections", "some_id"), ], ) diff --git a/test/test_virtual_connection.py b/test/test_virtual_connection.py new file mode 100644 index 000000000..975033d2d --- /dev/null +++ b/test/test_virtual_connection.py @@ -0,0 +1,242 @@ +import json +from pathlib import Path +import unittest + +import requests_mock + +import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import parse_datetime +from tableauserverclient.models.virtual_connection_item import VirtualConnectionItem + +ASSET_DIR = Path(__file__).parent / "assets" + +VIRTUAL_CONNECTION_GET_XML = ASSET_DIR / "virtual_connections_get.xml" +VIRTUAL_CONNECTION_POPULATE_CONNECTIONS = ASSET_DIR / "virtual_connection_populate_connections.xml" +VC_DB_CONN_UPDATE = ASSET_DIR / "virtual_connection_database_connection_update.xml" +VIRTUAL_CONNECTION_DOWNLOAD = ASSET_DIR / "virtual_connections_download.xml" +VIRTUAL_CONNECTION_UPDATE = ASSET_DIR / "virtual_connections_update.xml" +VIRTUAL_CONNECTION_REVISIONS = ASSET_DIR / "virtual_connections_revisions.xml" +VIRTUAL_CONNECTION_PUBLISH = ASSET_DIR / "virtual_connections_publish.xml" +ADD_PERMISSIONS = ASSET_DIR / "virtual_connection_add_permissions.xml" + + +class TestVirtualConnections(unittest.TestCase): + def setUp(self) -> None: + self.server = TSC.Server("http://test") + + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + self.server.version = "3.23" + + self.baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/virtualConnections" + return super().setUp() + + def test_from_xml(self): + items = VirtualConnectionItem.from_response(VIRTUAL_CONNECTION_GET_XML.read_bytes(), self.server.namespace) + + assert len(items) == 1 + virtual_connection = items[0] + assert virtual_connection.created_at == parse_datetime("2024-05-30T09:00:00Z") + assert not virtual_connection.has_extracts + assert virtual_connection.id == "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + assert virtual_connection.is_certified + assert virtual_connection.name == "vconn" + assert virtual_connection.updated_at == parse_datetime("2024-06-18T09:00:00Z") + assert virtual_connection.webpage_url == "https://test/#/site/site-name/virtualconnections/3" + + def test_virtual_connection_get(self): + with requests_mock.mock() as m: + m.get(self.baseurl, text=VIRTUAL_CONNECTION_GET_XML.read_text()) + items, pagination_item = self.server.virtual_connections.get() + + assert len(items) == 1 + assert pagination_item.total_available == 1 + assert items[0].name == "vconn" + + def test_virtual_connection_populate_connections(self): + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{vconn.id}/connections", text=VIRTUAL_CONNECTION_POPULATE_CONNECTIONS.read_text()) + vc_out = self.server.virtual_connections.populate_connections(vconn) + connection_list = list(vconn.connections) + + assert vc_out is vconn + assert vc_out._connections is not None + + assert len(connection_list) == 1 + connection = connection_list[0] + assert connection.id == "37ca6ced-58d7-4dcf-99dc-f0a85223cbef" + assert connection.connection_type == "postgres" + assert connection.server_address == "localhost" + assert connection.server_port == "5432" + assert connection.username == "pgadmin" + + def test_virtual_connection_update_connection_db_connection(self): + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + connection = TSC.ConnectionItem() + connection._id = "37ca6ced-58d7-4dcf-99dc-f0a85223cbef" + connection.server_address = "localhost" + connection.server_port = "5432" + connection.username = "pgadmin" + connection.password = "password" + with requests_mock.mock() as m: + m.put(f"{self.baseurl}/{vconn.id}/connections/{connection.id}/modify", text=VC_DB_CONN_UPDATE.read_text()) + updated_connection = self.server.virtual_connections.update_connection_db_connection(vconn, connection) + + assert updated_connection.id == "37ca6ced-58d7-4dcf-99dc-f0a85223cbef" + assert updated_connection.server_address == "localhost" + assert updated_connection.server_port == "5432" + assert updated_connection.username == "pgadmin" + assert updated_connection.password is None + + def test_virtual_connection_get_by_id(self): + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{vconn.id}", text=VIRTUAL_CONNECTION_DOWNLOAD.read_text()) + vconn = self.server.virtual_connections.get_by_id(vconn) + + assert vconn.content + assert vconn.created_at is None + assert vconn.id is None + assert "policyCollection" in vconn.content + assert "revision" in vconn.content + + def test_virtual_connection_update(self): + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + vconn.is_certified = True + vconn.certification_note = "demo certification note" + vconn.project_id = "5286d663-8668-4ac2-8c8d-91af7d585f6b" + vconn.owner_id = "9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0" + with requests_mock.mock() as m: + m.put(f"{self.baseurl}/{vconn.id}", text=VIRTUAL_CONNECTION_UPDATE.read_text()) + vconn = self.server.virtual_connections.update(vconn) + + assert not vconn.has_extracts + assert vconn.id is None + assert vconn.is_certified + assert vconn.name == "testv1" + assert vconn.certification_note == "demo certification note" + assert vconn.project_id == "5286d663-8668-4ac2-8c8d-91af7d585f6b" + assert vconn.owner_id == "9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0" + + def test_virtual_connection_get_revisions(self): + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{vconn.id}/revisions", text=VIRTUAL_CONNECTION_REVISIONS.read_text()) + revisions, pagination_item = self.server.virtual_connections.get_revisions(vconn) + + assert len(revisions) == 3 + assert pagination_item.total_available == 3 + assert revisions[0].resource_id == vconn.id + assert revisions[0].resource_name == vconn.name + assert revisions[0].created_at == parse_datetime("2016-07-26T20:34:56Z") + assert revisions[0].revision_number == "1" + assert not revisions[0].current + assert not revisions[0].deleted + assert revisions[0].user_name == "Cassie" + assert revisions[0].user_id == "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" + assert revisions[1].resource_id == vconn.id + assert revisions[1].resource_name == vconn.name + assert revisions[1].created_at == parse_datetime("2016-07-27T20:34:56Z") + assert revisions[1].revision_number == "2" + assert not revisions[1].current + assert not revisions[1].deleted + assert revisions[2].resource_id == vconn.id + assert revisions[2].resource_name == vconn.name + assert revisions[2].created_at == parse_datetime("2016-07-28T20:34:56Z") + assert revisions[2].revision_number == "3" + assert revisions[2].current + assert not revisions[2].deleted + assert revisions[2].user_name == "Cassie" + assert revisions[2].user_id == "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" + + def test_virtual_connection_download_revision(self): + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{vconn.id}/revisions/1", text=VIRTUAL_CONNECTION_DOWNLOAD.read_text()) + content = self.server.virtual_connections.download_revision(vconn, 1) + + assert content + assert "policyCollection" in content + data = json.loads(content) + assert "policyCollection" in data + assert "revision" in data + + def test_virtual_connection_delete(self): + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + with requests_mock.mock() as m: + m.delete(f"{self.baseurl}/{vconn.id}") + self.server.virtual_connections.delete(vconn) + self.server.virtual_connections.delete(vconn.id) + + assert m.call_count == 2 + + def test_virtual_connection_publish(self): + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + vconn.project_id = "9836791c-9468-40f0-b7f3-d10b9562a046" + vconn.owner_id = "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + with requests_mock.mock() as m: + m.post(f"{self.baseurl}?overwrite=false&publishAsDraft=false", text=VIRTUAL_CONNECTION_PUBLISH.read_text()) + vconn = self.server.virtual_connections.publish( + vconn, '{"test": 0}', mode="CreateNew", publish_as_draft=False + ) + + assert vconn.name == "vconn_test" + assert vconn.owner_id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert vconn.project_id == "9836791c-9468-40f0-b7f3-d10b9562a046" + assert vconn.content + assert "policyCollection" in vconn.content + assert "revision" in vconn.content + + def test_virtual_connection_publish_draft_overwrite(self): + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + vconn.project_id = "9836791c-9468-40f0-b7f3-d10b9562a046" + vconn.owner_id = "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + with requests_mock.mock() as m: + m.post(f"{self.baseurl}?overwrite=true&publishAsDraft=true", text=VIRTUAL_CONNECTION_PUBLISH.read_text()) + vconn = self.server.virtual_connections.publish( + vconn, '{"test": 0}', mode="Overwrite", publish_as_draft=True + ) + + assert vconn.name == "vconn_test" + assert vconn.owner_id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert vconn.project_id == "9836791c-9468-40f0-b7f3-d10b9562a046" + assert vconn.content + assert "policyCollection" in vconn.content + assert "revision" in vconn.content + + def test_add_permissions(self) -> None: + with open(ADD_PERMISSIONS, "rb") as f: + response_xml = f.read().decode("utf-8") + + single_virtual_connection = TSC.VirtualConnectionItem("test") + single_virtual_connection._id = "21778de4-b7b9-44bc-a599-1506a2639ace" + + bob = TSC.UserItem.as_reference("7c37ee24-c4b1-42b6-a154-eaeab7ee330a") + group_of_people = TSC.GroupItem.as_reference("5e5e1978-71fa-11e4-87dd-7382f5c437af") + + new_permissions = [ + TSC.PermissionsRule(bob, {"Write": "Allow"}), + TSC.PermissionsRule(group_of_people, {"Read": "Deny"}), + ] + + with requests_mock.mock() as m: + m.put(self.baseurl + "/21778de4-b7b9-44bc-a599-1506a2639ace/permissions", text=response_xml) + permissions = self.server.virtual_connections.add_permissions(single_virtual_connection, new_permissions) + + self.assertEqual(permissions[0].grantee.tag_name, "group") + self.assertEqual(permissions[0].grantee.id, "5e5e1978-71fa-11e4-87dd-7382f5c437af") + self.assertDictEqual(permissions[0].capabilities, {TSC.Permission.Capability.Read: TSC.Permission.Mode.Deny}) + + self.assertEqual(permissions[1].grantee.tag_name, "user") + self.assertEqual(permissions[1].grantee.id, "7c37ee24-c4b1-42b6-a154-eaeab7ee330a") + self.assertDictEqual(permissions[1].capabilities, {TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow}) From 60e6b9c14128a541d843d3687f2c08c81b1ec326 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 5 Sep 2024 22:44:58 -0500 Subject: [PATCH 229/296] feat: add as_reference method to groupset --- tableauserverclient/models/groupset_item.py | 5 +++++ tableauserverclient/models/permissions_item.py | 11 +++++++---- test/assets/flow_populate_permissions.xml | 9 ++++++++- test/test_flow.py | 10 ++++++++++ test/test_groupsets.py | 9 +++++++++ 5 files changed, 39 insertions(+), 5 deletions(-) diff --git a/tableauserverclient/models/groupset_item.py b/tableauserverclient/models/groupset_item.py index 879e8b02d..ffb57adf5 100644 --- a/tableauserverclient/models/groupset_item.py +++ b/tableauserverclient/models/groupset_item.py @@ -4,6 +4,7 @@ from defusedxml.ElementTree import fromstring from tableauserverclient.models.group_item import GroupItem +from tableauserverclient.models.reference_item import ResourceReference class GroupSetItem: @@ -46,3 +47,7 @@ def get_group(group_xml: ET.Element) -> GroupItem: ] return group_set_item + + @staticmethod + def as_reference(id_: str) -> ResourceReference: + return ResourceReference(id_, GroupSetItem.tag_name) diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index fecdb9723..26f4ee7e8 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -3,10 +3,11 @@ from defusedxml.ElementTree import fromstring -from .exceptions import UnknownGranteeTypeError, UnpopulatedPropertyError -from .group_item import GroupItem -from .reference_item import ResourceReference -from .user_item import UserItem +from tableauserverclient.models.exceptions import UnknownGranteeTypeError, UnpopulatedPropertyError +from tableauserverclient.models.group_item import GroupItem +from tableauserverclient.models.groupset_item import GroupSetItem +from tableauserverclient.models.reference_item import ResourceReference +from tableauserverclient.models.user_item import UserItem from tableauserverclient.helpers.logging import logger @@ -142,6 +143,8 @@ def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[Dict grantee = UserItem.as_reference(grantee_id) elif grantee_type == "group": grantee = GroupItem.as_reference(grantee_id) + elif grantee_type == "groupSet": + grantee = GroupSetItem.as_reference(grantee_id) else: raise UnknownGranteeTypeError("No support for grantee type of {}".format(grantee_type)) diff --git a/test/assets/flow_populate_permissions.xml b/test/assets/flow_populate_permissions.xml index 59fe5bd67..ce3a22f97 100644 --- a/test/assets/flow_populate_permissions.xml +++ b/test/assets/flow_populate_permissions.xml @@ -11,5 +11,12 @@ + + + + + + + - \ No newline at end of file + diff --git a/test/test_flow.py b/test/test_flow.py index a90b18171..d458bc77b 100644 --- a/test/test_flow.py +++ b/test/test_flow.py @@ -142,6 +142,16 @@ def test_populate_permissions(self) -> None: }, ) + self.assertEqual(permissions[1].grantee.tag_name, "groupSet") + self.assertEqual(permissions[1].grantee.id, "7ea95a1b-6872-44d6-a969-68598a7df4a0") + self.assertDictEqual( + permissions[1].capabilities, + { + TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + }, + ) + def test_publish(self) -> None: with open(PUBLISH_XML, "rb") as f: response_xml = f.read().decode("utf-8") diff --git a/test/test_groupsets.py b/test/test_groupsets.py index d3c9085a4..5479809d2 100644 --- a/test/test_groupsets.py +++ b/test/test_groupsets.py @@ -5,6 +5,7 @@ import requests_mock import tableauserverclient as TSC +from tableauserverclient.models.reference_item import ResourceReference TEST_ASSET_DIR = Path(__file__).parent / "assets" GROUPSET_CREATE = TEST_ASSET_DIR / "groupsets_create.xml" @@ -128,3 +129,11 @@ def test_remove_group(self) -> None: assert len(history) == 1 assert history[0].method == "DELETE" assert history[0].url == f"{self.baseurl}/{groupset.id}/groups/{group._id}" + + def test_as_reference(self) -> None: + groupset = TSC.GroupSetItem() + groupset.id = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + ref = groupset.as_reference(groupset.id) + assert ref.id == groupset.id + assert ref.tag_name == groupset.tag_name + assert isinstance(ref, ResourceReference) From 28c24875625e9b3d50a29a6d1d10162fb7d06a19 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Tue, 17 Sep 2024 13:24:04 -0700 Subject: [PATCH 230/296] Add support for all types of monthly repeating schedules (#1462) Previously we just handled monthly schedules which repeated on a day (1-31) or 'LastDay'. Tableau Server has since added more options such as "first Monday". This change catches up the interval validation to match what might be received from the server. Fixes #1358 * Add failing test for "monthly on first Monday" schedule * Add support for all monthly schedule variations * Unrelated fix for debug logging of API responses and add a small warning --- tableauserverclient/models/interval_item.py | 41 ++++++++++++------- .../server/endpoint/endpoint.py | 4 +- test/assets/schedule_get_monthly_id_2.xml | 12 ++++++ test/test_schedule.py | 16 ++++++++ 4 files changed, 58 insertions(+), 15 deletions(-) create mode 100644 test/assets/schedule_get_monthly_id_2.xml diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index 3ee1fee08..444674e19 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -246,21 +246,34 @@ def interval(self): @interval.setter def interval(self, interval_values): - # This is weird because the value could be a str or an int - # The only valid str is 'LastDay' so we check that first. If that's not it - # try to convert it to an int, if that fails because it's an incorrect string - # like 'badstring' we catch and re-raise. Otherwise we convert to int and check - # that it's in range 1-31 + # Valid monthly intervals strings can contain any of the following + # day numbers (1-31) (integer or string) + # relative day within the month (First, Second, ... Last) + # week days (Sunday, Monday, ... LastDay) + VALID_INTERVALS = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "LastDay", + "First", + "Second", + "Third", + "Fourth", + "Fifth", + "Last", + ] + for value in range(1, 32): + VALID_INTERVALS.append(str(value)) + VALID_INTERVALS.append(value) + for interval_value in interval_values: - error = "Invalid interval value for a monthly frequency: {}.".format(interval_value) - - if interval_value != "LastDay": - try: - if not (1 <= int(interval_value) <= 31): - raise ValueError(error) - except ValueError: - if interval_value != "LastDay": - raise ValueError(error) + if interval_value not in VALID_INTERVALS: + error = f"Invalid monthly interval: {interval_value}" + raise ValueError(error) self._interval = interval_values diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 0e55d5739..be0602df5 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -144,7 +144,9 @@ def _make_request( loggable_response = self.log_response_safely(server_response) logger.debug("Server response from {0}".format(url)) - # logger.debug("\n\t{1}".format(loggable_response)) + # uncomment the following to log full responses in debug mode + # BE CAREFUL WHEN SHARING THESE RESULTS - MAY CONTAIN YOUR SENSITIVE DATA + # logger.debug(loggable_response) if content_type == "application/xml": self.parent_srv._namespace.detect(server_response.content) diff --git a/test/assets/schedule_get_monthly_id_2.xml b/test/assets/schedule_get_monthly_id_2.xml new file mode 100644 index 000000000..ca84297e7 --- /dev/null +++ b/test/assets/schedule_get_monthly_id_2.xml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/test/test_schedule.py b/test/test_schedule.py index 3bbf5709b..0377295d7 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -14,6 +14,7 @@ GET_HOURLY_ID_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_hourly_id.xml") GET_DAILY_ID_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_daily_id.xml") GET_MONTHLY_ID_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_monthly_id.xml") +GET_MONTHLY_ID_2_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_monthly_id_2.xml") GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_empty.xml") CREATE_HOURLY_XML = os.path.join(TEST_ASSET_DIR, "schedule_create_hourly.xml") CREATE_DAILY_XML = os.path.join(TEST_ASSET_DIR, "schedule_create_daily.xml") @@ -158,6 +159,21 @@ def test_get_monthly_by_id(self) -> None: self.assertEqual("Active", schedule.state) self.assertEqual(("1", "2"), schedule.interval_item.interval) + def test_get_monthly_by_id_2(self) -> None: + self.server.version = "3.15" + with open(GET_MONTHLY_ID_2_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + schedule_id = "8c5caf33-6223-4724-83c3-ccdc1e730a07" + baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + m.get(baseurl, text=response_xml) + schedule = self.server.schedules.get_by_id(schedule_id) + self.assertIsNotNone(schedule) + self.assertEqual(schedule_id, schedule.id) + self.assertEqual("Monthly First Monday!", schedule.name) + self.assertEqual("Active", schedule.state) + self.assertEqual(("Monday", "First"), schedule.interval_item.interval) + def test_delete(self) -> None: with requests_mock.mock() as m: m.delete(self.baseurl + "/c9cff7f9-309c-4361-99ff-d4ba8c9f5467", status_code=204) From 4584717be8a71565720af55cd3265eb2b158344a Mon Sep 17 00:00:00 2001 From: "jac.fitzgerald" Date: Tue, 17 Sep 2024 13:43:47 -0700 Subject: [PATCH 231/296] README update to kick CLA bot --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 51da7bda0..5c80f337e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Use the Tableau Server Client (TSC) library to increase your productivity as you * Create users and groups. * Query projects, sites, and more. -This repository contains Python source code for the library and sample files showing how to use it. As of May 2022, Python versions 3.7 and up are supported. +This repository contains Python source code for the library and sample files showing how to use it. As of September 2024, support for Python 3.7 and 3.8 will be dropped - support for older versions of Python aims to match https://devguide.python.org/versions/ To see sample code that works directly with the REST API (in Java, Python, or Postman), visit the [REST API Samples](https://github.com/tableau/rest-api-samples) repo. From 60a3a2fef9880df686b8dd374677b54f47687faa Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Wed, 18 Sep 2024 23:46:43 -0700 Subject: [PATCH 232/296] Draft: Make urllib3 dependency more flexible (#1468) Make urllib3 dependency more flexible Per discussion in #1445 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3bf47ea23..b0428ec08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ '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.2.2', # dependabot + 'urllib3>=2.2.2,<3', 'typing_extensions>=4.0.1', ] requires-python = ">=3.7" From ac8dccd321e669114b7665c1afa4759031801778 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 19 Sep 2024 01:47:15 -0500 Subject: [PATCH 233/296] chore(versions): Upgrade minimum python version (#1465) * chore(versions): Upgrade minimum python version As of October, 2024, Python 3.8 is out of support. Upgrading syntax to target Python 3.9. Adds builds for Python 3.13. --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- .github/workflows/run-tests.yml | 2 +- pyproject.toml | 10 +- samples/add_default_permission.py | 4 +- samples/create_group.py | 13 +-- samples/create_project.py | 2 +- samples/create_schedules.py | 8 +- samples/explore_datasource.py | 17 +-- samples/explore_favorites.py | 10 +- samples/explore_site.py | 2 +- samples/explore_webhooks.py | 4 +- samples/explore_workbook.py | 33 +++--- samples/export.py | 6 +- samples/extracts.py | 2 +- samples/filter_sort_groups.py | 2 +- samples/filter_sort_projects.py | 2 +- samples/getting_started/1_hello_server.py | 4 +- samples/getting_started/2_hello_site.py | 4 +- samples/getting_started/3_hello_universe.py | 22 ++-- samples/initialize_server.py | 10 +- samples/list.py | 2 +- samples/login.py | 4 +- samples/move_workbook_sites.py | 8 +- samples/pagination_sample.py | 8 +- samples/publish_datasource.py | 4 +- samples/publish_workbook.py | 4 +- samples/query_permissions.py | 8 +- samples/refresh_tasks.py | 4 +- samples/update_workbook_data_acceleration.py | 2 +- .../update_workbook_data_freshness_policy.py | 2 +- tableauserverclient/_version.py | 18 +-- tableauserverclient/models/column_item.py | 2 +- .../models/connection_credentials.py | 2 +- tableauserverclient/models/connection_item.py | 12 +- .../models/custom_view_item.py | 10 +- .../models/data_acceleration_report_item.py | 4 +- tableauserverclient/models/data_alert_item.py | 10 +- .../models/data_freshness_policy_item.py | 12 +- tableauserverclient/models/database_item.py | 6 +- tableauserverclient/models/datasource_item.py | 20 ++-- tableauserverclient/models/dqw_item.py | 2 +- tableauserverclient/models/favorites_item.py | 8 +- tableauserverclient/models/fileupload_item.py | 2 +- tableauserverclient/models/flow_item.py | 12 +- tableauserverclient/models/flow_run_item.py | 6 +- tableauserverclient/models/group_item.py | 8 +- tableauserverclient/models/groupset_item.py | 8 +- tableauserverclient/models/interval_item.py | 18 +-- tableauserverclient/models/job_item.py | 16 +-- .../models/linked_tasks_item.py | 10 +- tableauserverclient/models/metric_item.py | 10 +- tableauserverclient/models/pagination_item.py | 2 +- .../models/permissions_item.py | 20 ++-- tableauserverclient/models/project_item.py | 10 +- .../models/property_decorators.py | 23 ++-- tableauserverclient/models/reference_item.py | 4 +- tableauserverclient/models/revision_item.py | 6 +- tableauserverclient/models/schedule_item.py | 4 +- .../models/server_info_item.py | 6 +- tableauserverclient/models/site_item.py | 6 +- .../models/subscription_item.py | 6 +- tableauserverclient/models/table_item.py | 2 +- tableauserverclient/models/tableau_auth.py | 10 +- tableauserverclient/models/tableau_types.py | 2 +- tableauserverclient/models/tag_item.py | 7 +- tableauserverclient/models/task_item.py | 8 +- tableauserverclient/models/user_item.py | 34 +++--- tableauserverclient/models/view_item.py | 21 ++-- .../models/virtual_connection_item.py | 11 +- tableauserverclient/models/webhook_item.py | 12 +- tableauserverclient/models/workbook_item.py | 24 ++-- tableauserverclient/namespace.py | 2 +- .../server/endpoint/auth_endpoint.py | 16 +-- .../server/endpoint/custom_views_endpoint.py | 24 ++-- .../data_acceleration_report_endpoint.py | 4 +- .../server/endpoint/data_alert_endpoint.py | 28 ++--- .../server/endpoint/databases_endpoint.py | 25 ++-- .../server/endpoint/datasources_endpoint.py | 97 ++++++++------- .../endpoint/default_permissions_endpoint.py | 27 +++-- .../server/endpoint/dqw_endpoint.py | 18 +-- .../server/endpoint/endpoint.py | 35 +++--- .../server/endpoint/exceptions.py | 6 +- .../server/endpoint/favorites_endpoint.py | 62 +++++----- .../server/endpoint/fileuploads_endpoint.py | 20 ++-- .../server/endpoint/flow_runs_endpoint.py | 18 +-- .../server/endpoint/flow_task_endpoint.py | 4 +- .../server/endpoint/flows_endpoint.py | 59 +++++----- .../server/endpoint/groups_endpoint.py | 35 +++--- .../server/endpoint/groupsets_endpoint.py | 4 +- .../server/endpoint/jobs_endpoint.py | 14 +-- .../server/endpoint/linked_tasks_endpoint.py | 4 +- .../server/endpoint/metadata_endpoint.py | 4 +- .../server/endpoint/metrics_endpoint.py | 20 ++-- .../server/endpoint/permissions_endpoint.py | 28 +++-- .../server/endpoint/projects_endpoint.py | 20 ++-- .../server/endpoint/resource_tagger.py | 27 ++--- .../server/endpoint/schedules_endpoint.py | 32 ++--- .../server/endpoint/server_info_endpoint.py | 4 +- .../server/endpoint/sites_endpoint.py | 34 +++--- .../server/endpoint/subscriptions_endpoint.py | 20 ++-- .../server/endpoint/tables_endpoint.py | 29 ++--- .../server/endpoint/tasks_endpoint.py | 16 +-- .../server/endpoint/users_endpoint.py | 38 +++--- .../server/endpoint/views_endpoint.py | 37 +++--- .../endpoint/virtual_connections_endpoint.py | 11 +- .../server/endpoint/webhooks_endpoint.py | 22 ++-- .../server/endpoint/workbooks_endpoint.py | 110 ++++++++---------- tableauserverclient/server/filter.py | 4 +- tableauserverclient/server/pager.py | 11 +- tableauserverclient/server/query.py | 19 ++- tableauserverclient/server/request_factory.py | 73 ++++++------ tableauserverclient/server/request_options.py | 12 +- tableauserverclient/server/server.py | 18 +-- tableauserverclient/server/sort.py | 4 +- test/test_dataalert.py | 2 +- test/test_datasource.py | 10 +- test/test_endpoint.py | 2 +- test/test_favorites.py | 18 +-- test/test_filesys_helpers.py | 2 +- test/test_fileuploads.py | 6 +- test/test_flowruns.py | 6 +- test/test_flowtask.py | 2 +- test/test_group.py | 1 - test/test_job.py | 8 +- test/test_project.py | 36 +++--- test/test_regression_tests.py | 6 +- test/test_request_option.py | 14 +-- test/test_schedule.py | 16 +-- test/test_site_model.py | 2 - test/test_tagging.py | 4 +- test/test_task.py | 8 +- test/test_user.py | 7 +- test/test_user_model.py | 9 +- test/test_view.py | 6 +- test/test_view_acceleration.py | 2 +- test/test_workbook.py | 12 +- versioneer.py | 47 ++++---- 136 files changed, 948 insertions(+), 990 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index d70539582..7e1533eef 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -13,7 +13,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13-dev'] runs-on: ${{ matrix.os }} diff --git a/pyproject.toml b/pyproject.toml index b0428ec08..cc3bf8fab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,26 +18,26 @@ dependencies = [ 'urllib3>=2.2.2,<3', 'typing_extensions>=4.0.1', ] -requires-python = ">=3.7" +requires-python = ">=3.9" classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12" + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13" ] [project.urls] repository = "https://github.com/tableau/server-client-python" [project.optional-dependencies] -test = ["black==23.7", "build", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", +test = ["black==24.8", "build", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", "requests-mock>=1.0,<2.0"] [tool.black] line-length = 120 -target-version = ['py37', 'py38', 'py39', 'py310', 'py311', 'py312'] +target-version = ['py39', 'py310', 'py311', 'py312', 'py313'] [tool.mypy] check_untyped_defs = false diff --git a/samples/add_default_permission.py b/samples/add_default_permission.py index 5a450e8ab..d26d009e2 100644 --- a/samples/add_default_permission.py +++ b/samples/add_default_permission.py @@ -63,10 +63,10 @@ def main(): for permission in new_default_permissions: grantee = permission.grantee capabilities = permission.capabilities - print("\nCapabilities for {0} {1}:".format(grantee.tag_name, grantee.id)) + print(f"\nCapabilities for {grantee.tag_name} {grantee.id}:") for capability in capabilities: - print("\t{0} - {1}".format(capability, capabilities[capability])) + print(f"\t{capability} - {capabilities[capability]}") # Uncomment lines below to DELETE the new capability and the new project # rules_to_delete = TSC.PermissionsRule( diff --git a/samples/create_group.py b/samples/create_group.py index f4c6a9ca9..aca3e895b 100644 --- a/samples/create_group.py +++ b/samples/create_group.py @@ -11,7 +11,6 @@ import os from datetime import time -from typing import List import tableauserverclient as TSC from tableauserverclient import ServerResponseError @@ -63,23 +62,23 @@ def main(): if args.file: filepath = os.path.abspath(args.file) - print("Add users to site from file {}:".format(filepath)) - added: List[TSC.UserItem] - failed: List[TSC.UserItem, TSC.ServerResponseError] + print(f"Add users to site from file {filepath}:") + added: list[TSC.UserItem] + failed: list[TSC.UserItem, TSC.ServerResponseError] added, failed = server.users.create_from_file(filepath) for user, error in failed: print(user, error.code) if error.code == "409017": user = server.users.filter(name=user.name)[0] added.append(user) - print("Adding users to group:{}".format(added)) + print(f"Adding users to group:{added}") for user in added: - print("Adding user {}".format(user)) + print(f"Adding user {user}") try: server.groups.add_user(group, user.id) except ServerResponseError as serverError: if serverError.code == "409011": - print("user {} is already a member of group {}".format(user.name, group.name)) + print(f"user {user.name} is already a member of group {group.name}") else: raise rError diff --git a/samples/create_project.py b/samples/create_project.py index 1fc649f8c..d775902aa 100644 --- a/samples/create_project.py +++ b/samples/create_project.py @@ -84,7 +84,7 @@ def main(): server.projects.populate_datasource_default_permissions(changed_project), server.projects.populate_permissions(changed_project) # Projects have default permissions set for the object types they contain - print("Permissions from project {}:".format(changed_project.id)) + print(f"Permissions from project {changed_project.id}:") print(changed_project.permissions) print( changed_project.default_workbook_permissions, diff --git a/samples/create_schedules.py b/samples/create_schedules.py index dee088571..c23a2eced 100644 --- a/samples/create_schedules.py +++ b/samples/create_schedules.py @@ -55,7 +55,7 @@ def main(): ) try: hourly_schedule = server.schedules.create(hourly_schedule) - print("Hourly schedule created (ID: {}).".format(hourly_schedule.id)) + print(f"Hourly schedule created (ID: {hourly_schedule.id}).") except Exception as e: print(e) @@ -71,7 +71,7 @@ def main(): ) try: daily_schedule = server.schedules.create(daily_schedule) - print("Daily schedule created (ID: {}).".format(daily_schedule.id)) + print(f"Daily schedule created (ID: {daily_schedule.id}).") except Exception as e: print(e) @@ -89,7 +89,7 @@ def main(): ) try: weekly_schedule = server.schedules.create(weekly_schedule) - print("Weekly schedule created (ID: {}).".format(weekly_schedule.id)) + print(f"Weekly schedule created (ID: {weekly_schedule.id}).") except Exception as e: print(e) options = TSC.RequestOptions() @@ -112,7 +112,7 @@ def main(): ) try: monthly_schedule = server.schedules.create(monthly_schedule) - print("Monthly schedule created (ID: {}).".format(monthly_schedule.id)) + print(f"Monthly schedule created (ID: {monthly_schedule.id}).") except Exception as e: print(e) diff --git a/samples/explore_datasource.py b/samples/explore_datasource.py index fb45cb45e..877c5f08d 100644 --- a/samples/explore_datasource.py +++ b/samples/explore_datasource.py @@ -54,13 +54,13 @@ def main(): new_datasource = server.datasources.publish( new_datasource, args.publish, TSC.Server.PublishMode.Overwrite ) - print("Datasource published. ID: {}".format(new_datasource.id)) + print(f"Datasource published. ID: {new_datasource.id}") else: print("Publish failed. Could not find the default project.") # Gets all datasource items all_datasources, pagination_item = server.datasources.get() - print("\nThere are {} datasources on site: ".format(pagination_item.total_available)) + print(f"\nThere are {pagination_item.total_available} datasources on site: ") print([datasource.name for datasource in all_datasources]) if all_datasources: @@ -69,20 +69,15 @@ def main(): # Populate connections server.datasources.populate_connections(sample_datasource) - print("\nConnections for {}: ".format(sample_datasource.name)) - print( - [ - "{0}({1})".format(connection.id, connection.datasource_name) - for connection in sample_datasource.connections - ] - ) + print(f"\nConnections for {sample_datasource.name}: ") + print([f"{connection.id}({connection.datasource_name})" for connection in sample_datasource.connections]) # Add some tags to the datasource original_tag_set = set(sample_datasource.tags) sample_datasource.tags.update("a", "b", "c", "d") server.datasources.update(sample_datasource) - print("\nOld tag set: {}".format(original_tag_set)) - print("New tag set: {}".format(sample_datasource.tags)) + print(f"\nOld tag set: {original_tag_set}") + print(f"New tag set: {sample_datasource.tags}") # Delete all tags that were added by setting tags to original sample_datasource.tags = original_tag_set diff --git a/samples/explore_favorites.py b/samples/explore_favorites.py index 243e91954..364e078cc 100644 --- a/samples/explore_favorites.py +++ b/samples/explore_favorites.py @@ -39,7 +39,7 @@ def main(): # get all favorites on site for the logged on user user: TSC.UserItem = TSC.UserItem() user.id = server.user_id - print("Favorites for user: {}".format(user.id)) + print(f"Favorites for user: {user.id}") server.favorites.get(user) print(user.favorites) @@ -57,7 +57,7 @@ def main(): if views is not None and len(views) > 0: my_view = views[0] server.favorites.add_favorite_view(user, my_view) - print("View added to favorites. View Name: {}, View ID: {}".format(my_view.name, my_view.id)) + print(f"View added to favorites. View Name: {my_view.name}, View ID: {my_view.id}") all_datasource_items, pagination_item = server.datasources.get() if all_datasource_items: @@ -70,12 +70,10 @@ def main(): ) server.favorites.delete_favorite_workbook(user, my_workbook) - print( - "Workbook deleted from favorites. Workbook Name: {}, Workbook ID: {}".format(my_workbook.name, my_workbook.id) - ) + print(f"Workbook deleted from favorites. Workbook Name: {my_workbook.name}, Workbook ID: {my_workbook.id}") server.favorites.delete_favorite_view(user, my_view) - print("View deleted from favorites. View Name: {}, View ID: {}".format(my_view.name, my_view.id)) + print(f"View deleted from favorites. View Name: {my_view.name}, View ID: {my_view.id}") server.favorites.delete_favorite_datasource(user, my_datasource) print( diff --git a/samples/explore_site.py b/samples/explore_site.py index a2274f1a7..eb9eba0de 100644 --- a/samples/explore_site.py +++ b/samples/explore_site.py @@ -49,7 +49,7 @@ def main(): if args.delete: print("You can only delete the site you are currently in") - print("Delete site `{}`?".format(current_site.name)) + print(f"Delete site `{current_site.name}`?") # server.sites.delete(server.site_id) elif args.create: diff --git a/samples/explore_webhooks.py b/samples/explore_webhooks.py index 77802b1db..f25c41849 100644 --- a/samples/explore_webhooks.py +++ b/samples/explore_webhooks.py @@ -52,11 +52,11 @@ def main(): new_webhook.event = "datasource-created" print(new_webhook) new_webhook = server.webhooks.create(new_webhook) - print("Webhook created. ID: {}".format(new_webhook.id)) + print(f"Webhook created. ID: {new_webhook.id}") # Gets all webhook items all_webhooks, pagination_item = server.webhooks.get() - print("\nThere are {} webhooks on site: ".format(pagination_item.total_available)) + print(f"\nThere are {pagination_item.total_available} webhooks on site: ") print([webhook.name for webhook in all_webhooks]) if all_webhooks: diff --git a/samples/explore_workbook.py b/samples/explore_workbook.py index 57f88aa07..f51639ab3 100644 --- a/samples/explore_workbook.py +++ b/samples/explore_workbook.py @@ -59,13 +59,13 @@ def main(): if default_project is not None: new_workbook = TSC.WorkbookItem(default_project.id) new_workbook = server.workbooks.publish(new_workbook, args.publish, overwrite_true) - print("Workbook published. ID: {}".format(new_workbook.id)) + print(f"Workbook published. ID: {new_workbook.id}") else: print("Publish failed. Could not find the default project.") # Gets all workbook items all_workbooks, pagination_item = server.workbooks.get() - print("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) + print(f"\nThere are {pagination_item.total_available} workbooks on site: ") print([workbook.name for workbook in all_workbooks]) if all_workbooks: @@ -78,27 +78,22 @@ def main(): # Populate views server.workbooks.populate_views(sample_workbook) - print("\nName of views in {}: ".format(sample_workbook.name)) + print(f"\nName of views in {sample_workbook.name}: ") print([view.name for view in sample_workbook.views]) # Populate connections server.workbooks.populate_connections(sample_workbook) - print("\nConnections for {}: ".format(sample_workbook.name)) - print( - [ - "{0}({1})".format(connection.id, connection.datasource_name) - for connection in sample_workbook.connections - ] - ) + print(f"\nConnections for {sample_workbook.name}: ") + print([f"{connection.id}({connection.datasource_name})" for connection in sample_workbook.connections]) # Update tags and show_tabs flag original_tag_set = set(sample_workbook.tags) sample_workbook.tags.update("a", "b", "c", "d") sample_workbook.show_tabs = True server.workbooks.update(sample_workbook) - print("\nWorkbook's old tag set: {}".format(original_tag_set)) - print("Workbook's new tag set: {}".format(sample_workbook.tags)) - print("Workbook tabbed: {}".format(sample_workbook.show_tabs)) + print(f"\nWorkbook's old tag set: {original_tag_set}") + print(f"Workbook's new tag set: {sample_workbook.tags}") + print(f"Workbook tabbed: {sample_workbook.show_tabs}") # Delete all tags that were added by setting tags to original sample_workbook.tags = original_tag_set @@ -109,8 +104,8 @@ def main(): original_tag_set = set(sample_view.tags) sample_view.tags.add("view_tag") server.views.update(sample_view) - print("\nView's old tag set: {}".format(original_tag_set)) - print("View's new tag set: {}".format(sample_view.tags)) + print(f"\nView's old tag set: {original_tag_set}") + print(f"View's new tag set: {sample_view.tags}") # Delete tag from just one view sample_view.tags = original_tag_set @@ -119,14 +114,14 @@ def main(): if args.download: # Download path = server.workbooks.download(sample_workbook.id, args.download) - print("\nDownloaded workbook to {}".format(path)) + print(f"\nDownloaded workbook to {path}") if args.preview_image: # Populate workbook preview image server.workbooks.populate_preview_image(sample_workbook) with open(args.preview_image, "wb") as f: f.write(sample_workbook.preview_image) - print("\nDownloaded preview image of workbook to {}".format(os.path.abspath(args.preview_image))) + print(f"\nDownloaded preview image of workbook to {os.path.abspath(args.preview_image)}") # get custom views cvs, _ = server.custom_views.get() @@ -153,10 +148,10 @@ def main(): 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))) + print(f"\nDownloaded powerpoint of workbook to {os.path.abspath(args.powerpoint)}") if args.delete: - print("deleting {}".format(c.id)) + print(f"deleting {c.id}") unlucky = TSC.CustomViewItem(c.id) server.custom_views.delete(unlucky.id) diff --git a/samples/export.py b/samples/export.py index f2783fa6e..815ec8b51 100644 --- a/samples/export.py +++ b/samples/export.py @@ -60,10 +60,10 @@ def main(): item = server.views.get_by_id(args.resource_id) if not item: - print("No item found for id {}".format(args.resource_id)) + print(f"No item found for id {args.resource_id}") exit(1) - print("Item found: {}".format(item.name)) + print(f"Item found: {item.name}") # We have a number of different types and functions for each different export type. # We encode that information above in the const=(...) parameter to the add_argument function to make # the code automatically adapt for the type of export the user is doing. @@ -83,7 +83,7 @@ def main(): if args.file: filename = args.file else: - filename = "out.{}".format(extension) + filename = f"out.{extension}" populate(item, options) with open(filename, "wb") as f: diff --git a/samples/extracts.py b/samples/extracts.py index 9bd87a473..d21bfdd0b 100644 --- a/samples/extracts.py +++ b/samples/extracts.py @@ -47,7 +47,7 @@ def main(): with server.auth.sign_in(tableau_auth): # Gets all workbook items all_workbooks, pagination_item = server.workbooks.get() - print("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) + print(f"\nThere are {pagination_item.total_available} workbooks on site: ") print([workbook.name for workbook in all_workbooks]) if all_workbooks: diff --git a/samples/filter_sort_groups.py b/samples/filter_sort_groups.py index 042af32e2..d967659ad 100644 --- a/samples/filter_sort_groups.py +++ b/samples/filter_sort_groups.py @@ -71,7 +71,7 @@ def main(): group_name = filtered_groups.pop().name print(group_name) else: - error = "No project named '{}' found".format(filter_group_name) + error = f"No project named '{filter_group_name}' found" print(error) # Or, try the above with the django style filtering diff --git a/samples/filter_sort_projects.py b/samples/filter_sort_projects.py index 7aa62a5c1..6c3a85dcd 100644 --- a/samples/filter_sort_projects.py +++ b/samples/filter_sort_projects.py @@ -68,7 +68,7 @@ def main(): project_name = filtered_projects.pop().name print(project_name) else: - error = "No project named '{}' found".format(filter_project_name) + error = f"No project named '{filter_project_name}' found" print(error) create_example_project(name="Example 1", server=server) diff --git a/samples/getting_started/1_hello_server.py b/samples/getting_started/1_hello_server.py index 454b225de..5f8cfa238 100644 --- a/samples/getting_started/1_hello_server.py +++ b/samples/getting_started/1_hello_server.py @@ -12,8 +12,8 @@ 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(f"Connected to {server.server_info.baseurl}") + print(f"Server information: {server.server_info}") print("Sign up for a test site at https://www.tableau.com/developer") diff --git a/samples/getting_started/2_hello_site.py b/samples/getting_started/2_hello_site.py index d62896059..8635947a8 100644 --- a/samples/getting_started/2_hello_site.py +++ b/samples/getting_started/2_hello_site.py @@ -19,7 +19,7 @@ def main(): 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)) + print(f"Connected to {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 @@ -39,7 +39,7 @@ def main(): with server.auth.sign_in(tableau_auth): projects, pagination = server.projects.get() if projects: - print("{} projects".format(pagination.total_available)) + print(f"{pagination.total_available} projects") project = projects[0] print(project.name) diff --git a/samples/getting_started/3_hello_universe.py b/samples/getting_started/3_hello_universe.py index 21de97831..a2c4301d0 100644 --- a/samples/getting_started/3_hello_universe.py +++ b/samples/getting_started/3_hello_universe.py @@ -17,7 +17,7 @@ def main(): 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)) + print(f"Connected to {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/ @@ -36,55 +36,55 @@ def main(): with server.auth.sign_in(tableau_auth): projects, pagination = server.projects.get() if projects: - print("{} projects".format(pagination.total_available)) + print(f"{pagination.total_available} projects") for project in projects: print(project.name) workbooks, pagination = server.datasources.get() if workbooks: - print("{} workbooks".format(pagination.total_available)) + print(f"{pagination.total_available} workbooks") print(workbooks[0]) views, pagination = server.views.get() if views: - print("{} views".format(pagination.total_available)) + print(f"{pagination.total_available} views") print(views[0]) datasources, pagination = server.datasources.get() if datasources: - print("{} datasources".format(pagination.total_available)) + print(f"{pagination.total_available} datasources") 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(f"{pagination.total_available} jobs") print(jobs[0]) schedules, pagination = server.schedules.get() if schedules: - print("{} schedules".format(pagination.total_available)) + print(f"{pagination.total_available} schedules") print(schedules[0]) tasks, pagination = server.tasks.get() if tasks: - print("{} tasks".format(pagination.total_available)) + print(f"{pagination.total_available} tasks") print(tasks[0]) webhooks, pagination = server.webhooks.get() if webhooks: - print("{} webhooks".format(pagination.total_available)) + print(f"{pagination.total_available} webhooks") print(webhooks[0]) users, pagination = server.users.get() if users: - print("{} users".format(pagination.total_available)) + print(f"{pagination.total_available} users") print(users[0]) groups, pagination = server.groups.get() if groups: - print("{} groups".format(pagination.total_available)) + print(f"{pagination.total_available} groups") print(groups[0]) diff --git a/samples/initialize_server.py b/samples/initialize_server.py index cb3d9e1d0..cdfaf27a8 100644 --- a/samples/initialize_server.py +++ b/samples/initialize_server.py @@ -51,7 +51,7 @@ def main(): # Create the site if it doesn't exist if existing_site is None: - print("Site not found: {0} Creating it...".format(args.site_id)) + print(f"Site not found: {args.site_id} Creating it...") new_site = TSC.SiteItem( name=args.site_id, content_url=args.site_id.replace(" ", ""), @@ -59,7 +59,7 @@ def main(): ) server.sites.create(new_site) else: - print("Site {0} exists. Moving on...".format(args.site_id)) + print(f"Site {args.site_id} exists. Moving on...") ################################################################################ # Step 3: Sign-in to our target site @@ -81,7 +81,7 @@ def main(): # Create our project if it doesn't exist if project is None: - print("Project not found: {0} Creating it...".format(args.project)) + print(f"Project not found: {args.project} Creating it...") new_project = TSC.ProjectItem(name=args.project) project = server_upload.projects.create(new_project) @@ -100,7 +100,7 @@ def publish_datasources_to_site(server_object, project, folder): for fname in glob.glob(path): new_ds = TSC.DatasourceItem(project.id) new_ds = server_object.datasources.publish(new_ds, fname, server_object.PublishMode.Overwrite) - print("Datasource published. ID: {0}".format(new_ds.id)) + print(f"Datasource published. ID: {new_ds.id}") def publish_workbooks_to_site(server_object, project, folder): @@ -110,7 +110,7 @@ def publish_workbooks_to_site(server_object, project, folder): new_workbook = TSC.WorkbookItem(project.id) new_workbook.show_tabs = True new_workbook = server_object.workbooks.publish(new_workbook, fname, server_object.PublishMode.Overwrite) - print("Workbook published. ID: {0}".format(new_workbook.id)) + print(f"Workbook published. ID: {new_workbook.id}") if __name__ == "__main__": diff --git a/samples/list.py b/samples/list.py index 8d72fb620..11e664695 100644 --- a/samples/list.py +++ b/samples/list.py @@ -59,7 +59,7 @@ def main(): print(resource.name[:18], " ") # , resource._connections()) if count > 100: break - print("Total: {}".format(count)) + print(f"Total: {count}") if __name__ == "__main__": diff --git a/samples/login.py b/samples/login.py index 6a3e9e8b3..847d3558f 100644 --- a/samples/login.py +++ b/samples/login.py @@ -59,7 +59,7 @@ def sample_connect_to_server(args): password = args.password or getpass.getpass("Password: ") tableau_auth = TSC.TableauAuth(args.username, password, site_id=args.site) - print("\nSigning in...\nServer: {}\nSite: {}\nUsername: {}".format(args.server, args.site, args.username)) + print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\nUsername: {args.username}") else: # Trying to authenticate using personal access tokens. @@ -68,7 +68,7 @@ def sample_connect_to_server(args): tableau_auth = TSC.PersonalAccessTokenAuth( token_name=args.token_name, personal_access_token=token, site_id=args.site ) - print("\nSigning in...\nServer: {}\nSite: {}\nToken name: {}".format(args.server, args.site, args.token_name)) + print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\nToken name: {args.token_name}") if not tableau_auth: raise TabError("Did not create authentication object. Check arguments.") diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index 47af1f2f9..e82c75cf9 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -59,7 +59,7 @@ def main(): # Step 3: Download workbook to a temp directory if len(all_workbooks) == 0: - print("No workbook named {} found.".format(args.workbook_name)) + print(f"No workbook named {args.workbook_name} found.") else: tmpdir = tempfile.mkdtemp() try: @@ -68,10 +68,10 @@ def main(): # Step 4: Check if destination site exists, then sign in to the site all_sites, pagination_info = source_server.sites.get() found_destination_site = any( - (True for site in all_sites if args.destination_site.lower() == site.content_url.lower()) + True for site in all_sites if args.destination_site.lower() == site.content_url.lower() ) if not found_destination_site: - error = "No site named {} found.".format(args.destination_site) + error = f"No site named {args.destination_site} found." raise LookupError(error) tableau_auth.site_id = args.destination_site @@ -85,7 +85,7 @@ def main(): new_workbook = dest_server.workbooks.publish( new_workbook, workbook_path, mode=TSC.Server.PublishMode.Overwrite ) - print("Successfully moved {0} ({1})".format(new_workbook.name, new_workbook.id)) + print(f"Successfully moved {new_workbook.name} ({new_workbook.id})") # Step 6: Delete workbook from source site and delete temp directory source_server.workbooks.delete(all_workbooks[0].id) diff --git a/samples/pagination_sample.py b/samples/pagination_sample.py index a7ae6dc89..a68eed4b3 100644 --- a/samples/pagination_sample.py +++ b/samples/pagination_sample.py @@ -57,7 +57,7 @@ def main(): for wb in TSC.Pager(server.workbooks, page_options): print(wb.name) count = count + 1 - print("Total: {}\n".format(count)) + print(f"Total: {count}\n") count = 0 page_options = TSC.RequestOptions(2, 3) @@ -65,7 +65,7 @@ def main(): for wb in TSC.Pager(server.workbooks, page_options): print(wb.name) count = count + 1 - print("Truncated Total: {}\n".format(count)) + print(f"Truncated Total: {count}\n") print("Your id: ", you.name, you.id, "\n") count = 0 @@ -76,7 +76,7 @@ def main(): for wb in TSC.Pager(server.workbooks, filtered_page_options): print(wb.name, " -- ", wb.owner_id) count = count + 1 - print("Filtered Total: {}\n".format(count)) + print(f"Filtered Total: {count}\n") # 2. QuerySet offers a fluent interface on top of the RequestOptions object print("Fetching workbooks again - this time filtered with QuerySet") @@ -90,7 +90,7 @@ def main(): count = count + 1 more = queryset.total_available > count page = page + 1 - print("QuerySet Total: {}".format(count)) + print(f"QuerySet Total: {count}") # 3. QuerySet also allows you to iterate over all objects without explicitly paging. print("Fetching again - this time without manually paging") diff --git a/samples/publish_datasource.py b/samples/publish_datasource.py index 5ac768674..85f63fb35 100644 --- a/samples/publish_datasource.py +++ b/samples/publish_datasource.py @@ -111,14 +111,14 @@ def main(): new_job = server.datasources.publish( new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds, as_job=True ) - print("Datasource published asynchronously. Job ID: {0}".format(new_job.id)) + print(f"Datasource published asynchronously. Job ID: {new_job.id}") else: # Normal publishing, returns a datasource_item new_datasource = server.datasources.publish( new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds ) print( - "{0}Datasource published. Datasource ID: {1}".format( + "{}Datasource published. Datasource ID: {}".format( new_datasource.id, tableauserverclient.datetime_helpers.timestamp() ) ) diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 8a9f45279..d31978c0f 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -80,7 +80,7 @@ def main(): as_job=args.as_job, skip_connection_check=args.skip_connection_check, ) - print("Workbook published. JOB ID: {0}".format(new_job.id)) + print(f"Workbook published. JOB ID: {new_job.id}") else: new_workbook = server.workbooks.publish( new_workbook, @@ -90,7 +90,7 @@ def main(): as_job=args.as_job, skip_connection_check=args.skip_connection_check, ) - print("Workbook published. ID: {0}".format(new_workbook.id)) + print(f"Workbook published. ID: {new_workbook.id}") else: error = "The default project could not be found." raise LookupError(error) diff --git a/samples/query_permissions.py b/samples/query_permissions.py index 4e509cd97..3309acd90 100644 --- a/samples/query_permissions.py +++ b/samples/query_permissions.py @@ -57,17 +57,15 @@ def main(): permissions = resource.permissions # Print result - print( - "\n{0} permission rule(s) found for {1} {2}.".format(len(permissions), args.resource_type, args.resource_id) - ) + print(f"\n{len(permissions)} permission rule(s) found for {args.resource_type} {args.resource_id}.") for permission in permissions: grantee = permission.grantee capabilities = permission.capabilities - print("\nCapabilities for {0} {1}:".format(grantee.tag_name, grantee.id)) + print(f"\nCapabilities for {grantee.tag_name} {grantee.id}:") for capability in capabilities: - print("\t{0} - {1}".format(capability, capabilities[capability])) + print(f"\t{capability} - {capabilities[capability]}") if __name__ == "__main__": diff --git a/samples/refresh_tasks.py b/samples/refresh_tasks.py index 03daedf16..c95000898 100644 --- a/samples/refresh_tasks.py +++ b/samples/refresh_tasks.py @@ -19,12 +19,12 @@ def handle_run(server, args): def handle_list(server, _): tasks, pagination = server.tasks.get() for task in tasks: - print("{}".format(task)) + print(f"{task}") def handle_info(server, args): task = server.tasks.get_by_id(args.id) - print("{}".format(task)) + print(f"{task}") def main(): diff --git a/samples/update_workbook_data_acceleration.py b/samples/update_workbook_data_acceleration.py index 75f12262f..57a1363ed 100644 --- a/samples/update_workbook_data_acceleration.py +++ b/samples/update_workbook_data_acceleration.py @@ -43,7 +43,7 @@ def main(): 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(f"\nThere are {pagination_item.total_available} workbooks on site: ") print([workbook.name for workbook in all_workbooks]) if all_workbooks: diff --git a/samples/update_workbook_data_freshness_policy.py b/samples/update_workbook_data_freshness_policy.py index 9e4d63dc1..c23e3717f 100644 --- a/samples/update_workbook_data_freshness_policy.py +++ b/samples/update_workbook_data_freshness_policy.py @@ -45,7 +45,7 @@ def main(): 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(f"\nThere are {pagination_item.total_available} workbooks on site: ") print([workbook.name for workbook in all_workbooks]) if all_workbooks: diff --git a/tableauserverclient/_version.py b/tableauserverclient/_version.py index d47374097..5d1dca9df 100644 --- a/tableauserverclient/_version.py +++ b/tableauserverclient/_version.py @@ -84,7 +84,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= stderr=(subprocess.PIPE if hide_stderr else None), ) break - except EnvironmentError: + except OSError: e = sys.exc_info()[1] if e.errno == errno.ENOENT: continue @@ -94,7 +94,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= return None, None else: if verbose: - print("unable to find command, tried %s" % (commands,)) + print("unable to find command, tried {}".format(commands)) return None, None stdout = p.communicate()[0].strip() if sys.version_info[0] >= 3: @@ -131,7 +131,7 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): root = os.path.dirname(root) # up a level if verbose: - print("Tried directories %s but none started with prefix %s" % (str(rootdirs), parentdir_prefix)) + print("Tried directories {} but none started with prefix {}".format(str(rootdirs), parentdir_prefix)) raise NotThisMethod("rootdir doesn't start with parentdir_prefix") @@ -144,7 +144,7 @@ def git_get_keywords(versionfile_abs): # _version.py. keywords = {} try: - f = open(versionfile_abs, "r") + f = open(versionfile_abs) for line in f.readlines(): if line.strip().startswith("git_refnames ="): mo = re.search(r'=\s*"(.*)"', line) @@ -159,7 +159,7 @@ def git_get_keywords(versionfile_abs): if mo: keywords["date"] = mo.group(1) f.close() - except EnvironmentError: + except OSError: pass return keywords @@ -183,11 +183,11 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): if verbose: print("keywords are unexpanded, not using") raise NotThisMethod("unexpanded keywords, not a git-archive tarball") - refs = set([r.strip() for r in refnames.strip("()").split(",")]) + refs = {r.strip() for r in refnames.strip("()").split(",")} # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. TAG = "tag: " - tags = set([r[len(TAG) :] for r in refs if r.startswith(TAG)]) + tags = {r[len(TAG) :] for r in refs if r.startswith(TAG)} if not tags: # Either we're using git < 1.8.3, or there really are no tags. We use # a heuristic: assume all version tags have a digit. The old git %d @@ -196,7 +196,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # between branches and tags. By ignoring refnames without digits, we # filter out many common branch names like "release" and # "stabilization", as well as "HEAD" and "master". - tags = set([r for r in refs if re.search(r"\d", r)]) + tags = {r for r in refs if re.search(r"\d", r)} if verbose: print("discarding '%s', no digits" % ",".join(refs - tags)) if verbose: @@ -299,7 +299,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if verbose: fmt = "tag '%s' doesn't start with prefix '%s'" print(fmt % (full_tag, tag_prefix)) - pieces["error"] = "tag '%s' doesn't start with prefix '%s'" % ( + pieces["error"] = "tag '{}' doesn't start with prefix '{}'".format( full_tag, tag_prefix, ) diff --git a/tableauserverclient/models/column_item.py b/tableauserverclient/models/column_item.py index df936e315..3a7416e28 100644 --- a/tableauserverclient/models/column_item.py +++ b/tableauserverclient/models/column_item.py @@ -3,7 +3,7 @@ from .property_decorators import property_not_empty -class ColumnItem(object): +class ColumnItem: def __init__(self, name, description=None): self._id = None self.description = description diff --git a/tableauserverclient/models/connection_credentials.py b/tableauserverclient/models/connection_credentials.py index d61bbb751..bb2cbbba9 100644 --- a/tableauserverclient/models/connection_credentials.py +++ b/tableauserverclient/models/connection_credentials.py @@ -1,7 +1,7 @@ from .property_decorators import property_is_boolean -class ConnectionCredentials(object): +class ConnectionCredentials: """Connection Credentials for Workbooks and Datasources publish request. Consider removing this object and other variables holding secrets diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 62ff530c9..937e43481 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -1,5 +1,5 @@ import logging -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -8,7 +8,7 @@ from tableauserverclient.helpers.logging import logger -class ConnectionItem(object): +class ConnectionItem: def __init__(self): self._datasource_id: Optional[str] = None self._datasource_name: Optional[str] = None @@ -48,7 +48,7 @@ def query_tagging(self, value: Optional[bool]): # if connection type = hyper, Snowflake, or Teradata, we can't change this value: it is always true if self._connection_type in ["hyper", "snowflake", "teradata"]: logger.debug( - "Cannot update value: Query tagging is always enabled for {} connections".format(self._connection_type) + f"Cannot update value: Query tagging is always enabled for {self._connection_type} connections" ) return self._query_tagging = value @@ -59,7 +59,7 @@ def __repr__(self): ) @classmethod - def from_response(cls, resp, ns) -> List["ConnectionItem"]: + def from_response(cls, resp, ns) -> list["ConnectionItem"]: all_connection_items = list() parsed_response = fromstring(resp) all_connection_xml = parsed_response.findall(".//t:connection", namespaces=ns) @@ -82,7 +82,7 @@ def from_response(cls, resp, ns) -> List["ConnectionItem"]: return all_connection_items @classmethod - def from_xml_element(cls, parsed_response, ns) -> List["ConnectionItem"]: + def from_xml_element(cls, parsed_response, ns) -> list["ConnectionItem"]: """ @@ -93,7 +93,7 @@ def from_xml_element(cls, parsed_response, ns) -> List["ConnectionItem"]: """ - all_connection_items: List["ConnectionItem"] = list() + all_connection_items: list["ConnectionItem"] = list() all_connection_xml = parsed_response.findall(".//t:connection", namespaces=ns) for connection_xml in all_connection_xml: diff --git a/tableauserverclient/models/custom_view_item.py b/tableauserverclient/models/custom_view_item.py index 246a19e7f..de917bf4a 100644 --- a/tableauserverclient/models/custom_view_item.py +++ b/tableauserverclient/models/custom_view_item.py @@ -2,7 +2,7 @@ from defusedxml import ElementTree from defusedxml.ElementTree import fromstring, tostring -from typing import Callable, List, Optional +from typing import Callable, Optional from .exceptions import UnpopulatedPropertyError from .user_item import UserItem @@ -11,7 +11,7 @@ from ..datetime_helpers import parse_datetime -class CustomViewItem(object): +class CustomViewItem: def __init__(self, id: Optional[str] = None, name: Optional[str] = None) -> None: self._content_url: Optional[str] = None # ? self._created_at: Optional["datetime"] = None @@ -35,7 +35,7 @@ def __repr__(self: "CustomViewItem"): owner_info = "" if self._owner: owner_info = " owner='{}'".format(self._owner.name or self._owner.id or "unknown") - return "".format(self.id, self.name, view_info, wb_info, owner_info) + return f"" def _set_image(self, image): self._image = image @@ -104,7 +104,7 @@ def from_response(cls, resp, ns, workbook_id="") -> Optional["CustomViewItem"]: return item[0] @classmethod - def list_from_response(cls, resp, ns, workbook_id="") -> List["CustomViewItem"]: + def list_from_response(cls, resp, ns, workbook_id="") -> list["CustomViewItem"]: return cls.from_xml_element(fromstring(resp), ns, workbook_id) """ @@ -121,7 +121,7 @@ def list_from_response(cls, resp, ns, workbook_id="") -> List["CustomViewItem"]: """ @classmethod - def from_xml_element(cls, parsed_response, ns, workbook_id="") -> List["CustomViewItem"]: + def from_xml_element(cls, parsed_response, ns, workbook_id="") -> list["CustomViewItem"]: all_view_items = list() all_view_xml = parsed_response.findall(".//t:customView", namespaces=ns) for custom_view_xml in all_view_xml: diff --git a/tableauserverclient/models/data_acceleration_report_item.py b/tableauserverclient/models/data_acceleration_report_item.py index 7424e6b95..3a8883bed 100644 --- a/tableauserverclient/models/data_acceleration_report_item.py +++ b/tableauserverclient/models/data_acceleration_report_item.py @@ -1,8 +1,8 @@ from defusedxml.ElementTree import fromstring -class DataAccelerationReportItem(object): - class ComparisonRecord(object): +class DataAccelerationReportItem: + class ComparisonRecord: def __init__( self, site, diff --git a/tableauserverclient/models/data_alert_item.py b/tableauserverclient/models/data_alert_item.py index 65be233e3..7285ee609 100644 --- a/tableauserverclient/models/data_alert_item.py +++ b/tableauserverclient/models/data_alert_item.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -10,7 +10,7 @@ ) -class DataAlertItem(object): +class DataAlertItem: class Frequency: Once = "Once" Frequently = "Frequently" @@ -34,7 +34,7 @@ def __init__(self): self._workbook_name: Optional[str] = None self._project_id: Optional[str] = None self._project_name: Optional[str] = None - self._recipients: Optional[List[str]] = None + self._recipients: Optional[list[str]] = None def __repr__(self) -> str: return " Optional[str]: return self._creatorId @property - def recipients(self) -> List[str]: + def recipients(self) -> list[str]: return self._recipients or list() @property @@ -174,7 +174,7 @@ def _set_values( self._recipients = recipients @classmethod - def from_response(cls, resp, ns) -> List["DataAlertItem"]: + def from_response(cls, resp, ns) -> list["DataAlertItem"]: all_alert_items = list() parsed_response = fromstring(resp) all_alert_xml = parsed_response.findall(".//t:dataAlert", namespaces=ns) diff --git a/tableauserverclient/models/data_freshness_policy_item.py b/tableauserverclient/models/data_freshness_policy_item.py index f567c501c..6e0cb9001 100644 --- a/tableauserverclient/models/data_freshness_policy_item.py +++ b/tableauserverclient/models/data_freshness_policy_item.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as ET -from typing import Optional, Union, List +from typing import Optional from tableauserverclient.models.property_decorators import property_is_enum, property_not_nullable from .interval_item import IntervalItem @@ -50,11 +50,11 @@ class Frequency: Week = "Week" Month = "Month" - def __init__(self, frequency: str, time: str, timezone, interval_item: Optional[List[str]] = None): + 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 + self.interval_item: Optional[list[str]] = interval_item def __repr__(self): return ( @@ -62,11 +62,11 @@ def __repr__(self): ).format(**vars(self)) @property - def interval_item(self) -> Optional[List[str]]: + def interval_item(self) -> Optional[list[str]]: return self._interval_item @interval_item.setter - def interval_item(self, value: List[str]): + def interval_item(self, value: list[str]): self._interval_item = value @property @@ -186,7 +186,7 @@ def parse_week_intervals(interval_values): def parse_month_intervals(interval_values): - error = "Invalid interval value for a monthly frequency: {}.".format(interval_values) + error = f"Invalid interval value for a monthly frequency: {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 diff --git a/tableauserverclient/models/database_item.py b/tableauserverclient/models/database_item.py index dfc58e1bb..4d4604461 100644 --- a/tableauserverclient/models/database_item.py +++ b/tableauserverclient/models/database_item.py @@ -10,7 +10,7 @@ ) -class DatabaseItem(object): +class DatabaseItem: class ContentPermissions: LockedToProject = "LockedToDatabase" ManagedByOwner = "ManagedByOwner" @@ -45,7 +45,7 @@ def __init__(self, name, description=None, content_permissions=None): self._tables = None # Not implemented yet def __str__(self): - return "".format(self._id, self.name) + return f"" def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" @@ -250,7 +250,7 @@ def _set_tables(self, tables): self._tables = tables def _set_default_permissions(self, permissions, content_type): - attr = "_default_{content}_permissions".format(content=content_type) + attr = f"_default_{content_type}_permissions" setattr( self, attr, diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index e4e71c4a2..1b082c157 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -1,7 +1,7 @@ import copy import datetime import xml.etree.ElementTree as ET -from typing import Dict, List, Optional, Set, Tuple +from typing import Optional from defusedxml.ElementTree import fromstring @@ -18,14 +18,14 @@ from tableauserverclient.models.tag_item import TagItem -class DatasourceItem(object): +class DatasourceItem: class AskDataEnablement: Enabled = "Enabled" Disabled = "Disabled" SiteDefault = "SiteDefault" def __repr__(self): - return "".format( + return "".format( self._id, self.name, self.description or "No Description", @@ -44,7 +44,7 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) self._encrypt_extracts = None self._has_extracts = None self._id: Optional[str] = None - self._initial_tags: Set = set() + self._initial_tags: set = set() self._project_name: Optional[str] = None self._revisions = None self._size: Optional[int] = None @@ -55,7 +55,7 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) self.name = name self.owner_id: Optional[str] = None self.project_id = project_id - self.tags: Set[str] = set() + self.tags: set[str] = set() self._permissions = None self._data_quality_warnings = None @@ -72,14 +72,14 @@ def ask_data_enablement(self, value: Optional[AskDataEnablement]): self._ask_data_enablement = value @property - def connections(self) -> Optional[List[ConnectionItem]]: + def connections(self) -> Optional[list[ConnectionItem]]: if self._connections is None: error = "Datasource item must be populated with connections first." raise UnpopulatedPropertyError(error) return self._connections() @property - def permissions(self) -> Optional[List[PermissionsRule]]: + def permissions(self) -> Optional[list[PermissionsRule]]: if self._permissions is None: error = "Project item must be populated with permissions first." raise UnpopulatedPropertyError(error) @@ -177,7 +177,7 @@ def webpage_url(self) -> Optional[str]: return self._webpage_url @property - def revisions(self) -> List[RevisionItem]: + def revisions(self) -> list[RevisionItem]: if self._revisions is None: error = "Datasource item must be populated with revisions first." raise UnpopulatedPropertyError(error) @@ -309,7 +309,7 @@ def _set_values( self._size = int(size) @classmethod - def from_response(cls, resp: str, ns: Dict) -> List["DatasourceItem"]: + def from_response(cls, resp: str, ns: dict) -> list["DatasourceItem"]: all_datasource_items = list() parsed_response = fromstring(resp) all_datasource_xml = parsed_response.findall(".//t:datasource", namespaces=ns) @@ -326,7 +326,7 @@ def from_xml(cls, datasource_xml, ns): return datasource_item @staticmethod - def _parse_element(datasource_xml: ET.Element, ns: Dict) -> Tuple: + def _parse_element(datasource_xml: ET.Element, ns: dict) -> tuple: id_ = datasource_xml.get("id", None) name = datasource_xml.get("name", None) datasource_type = datasource_xml.get("type", None) diff --git a/tableauserverclient/models/dqw_item.py b/tableauserverclient/models/dqw_item.py index ada041481..fbda9d9f2 100644 --- a/tableauserverclient/models/dqw_item.py +++ b/tableauserverclient/models/dqw_item.py @@ -3,7 +3,7 @@ from tableauserverclient.datetime_helpers import parse_datetime -class DQWItem(object): +class DQWItem: class WarningType: WARNING = "WARNING" DEPRECATED = "DEPRECATED" diff --git a/tableauserverclient/models/favorites_item.py b/tableauserverclient/models/favorites_item.py index caff755e3..f157283cb 100644 --- a/tableauserverclient/models/favorites_item.py +++ b/tableauserverclient/models/favorites_item.py @@ -9,20 +9,18 @@ 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 -from typing import Dict, List, Union -FavoriteType = Dict[ +FavoriteType = dict[ str, - List[TableauItem], + list[TableauItem], ] class FavoriteItem: @classmethod - def from_response(cls, xml: str, namespace: Dict) -> FavoriteType: + def from_response(cls, xml: str, namespace: dict) -> FavoriteType: favorites: FavoriteType = { "datasources": [], "flows": [], diff --git a/tableauserverclient/models/fileupload_item.py b/tableauserverclient/models/fileupload_item.py index e9bdd25b2..aea4dfe1f 100644 --- a/tableauserverclient/models/fileupload_item.py +++ b/tableauserverclient/models/fileupload_item.py @@ -1,7 +1,7 @@ from defusedxml.ElementTree import fromstring -class FileuploadItem(object): +class FileuploadItem: def __init__(self): self._file_size = None self._upload_session_id = None diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index edce2ec97..9bcad5e89 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -1,7 +1,7 @@ import copy import datetime import xml.etree.ElementTree as ET -from typing import List, Optional, Set +from typing import Optional from defusedxml.ElementTree import fromstring @@ -14,9 +14,9 @@ from tableauserverclient.models.tag_item import TagItem -class FlowItem(object): +class FlowItem: def __repr__(self): - return " None: self._webpage_url: Optional[str] = None self._created_at: Optional[datetime.datetime] = None self._id: Optional[str] = None - self._initial_tags: Set[str] = set() + self._initial_tags: set[str] = set() self._project_name: Optional[str] = None self._updated_at: Optional[datetime.datetime] = None self.name: Optional[str] = name self.owner_id: Optional[str] = None self.project_id: str = project_id - self.tags: Set[str] = set() + self.tags: set[str] = set() self.description: Optional[str] = None self._connections: Optional[ConnectionItem] = None @@ -170,7 +170,7 @@ def _set_values( self.owner_id = owner_id @classmethod - def from_response(cls, resp, ns) -> List["FlowItem"]: + def from_response(cls, resp, ns) -> list["FlowItem"]: all_flow_items = list() parsed_response = fromstring(resp) all_flow_xml = parsed_response.findall(".//t:flow", namespaces=ns) diff --git a/tableauserverclient/models/flow_run_item.py b/tableauserverclient/models/flow_run_item.py index 12281f4f8..f2f1d561f 100644 --- a/tableauserverclient/models/flow_run_item.py +++ b/tableauserverclient/models/flow_run_item.py @@ -1,13 +1,13 @@ import itertools from datetime import datetime -from typing import Dict, List, Optional, Type +from typing import Optional from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime -class FlowRunItem(object): +class FlowRunItem: def __init__(self) -> None: self._id: str = "" self._flow_id: Optional[str] = None @@ -71,7 +71,7 @@ def _set_values( self._background_job_id = background_job_id @classmethod - def from_response(cls: Type["FlowRunItem"], resp: bytes, ns: Optional[Dict]) -> List["FlowRunItem"]: + def from_response(cls: type["FlowRunItem"], resp: bytes, ns: Optional[dict]) -> list["FlowRunItem"]: all_flowrun_items = list() parsed_response = fromstring(resp) all_flowrun_xml = itertools.chain( diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index 6c8f7eb01..6871f8b16 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -1,4 +1,4 @@ -from typing import Callable, List, Optional, TYPE_CHECKING +from typing import Callable, Optional, TYPE_CHECKING from defusedxml.ElementTree import fromstring @@ -11,7 +11,7 @@ from tableauserverclient.server import Pager -class GroupItem(object): +class GroupItem: tag_name: str = "group" class LicenseMode: @@ -27,7 +27,7 @@ def __init__(self, name=None, domain_name=None) -> None: self.domain_name: Optional[str] = domain_name def __repr__(self): - return "{}({!r})".format(self.__class__.__name__, self.__dict__) + return f"{self.__class__.__name__}({self.__dict__!r})" @property def domain_name(self) -> Optional[str]: @@ -79,7 +79,7 @@ def _set_users(self, users: Callable[..., "Pager"]) -> None: self._users = users @classmethod - def from_response(cls, resp, ns) -> List["GroupItem"]: + def from_response(cls, resp, ns) -> list["GroupItem"]: all_group_items = list() parsed_response = fromstring(resp) all_group_xml = parsed_response.findall(".//t:group", namespaces=ns) diff --git a/tableauserverclient/models/groupset_item.py b/tableauserverclient/models/groupset_item.py index ffb57adf5..aa653a79e 100644 --- a/tableauserverclient/models/groupset_item.py +++ b/tableauserverclient/models/groupset_item.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Optional +from typing import Optional import xml.etree.ElementTree as ET from defusedxml.ElementTree import fromstring @@ -13,7 +13,7 @@ class GroupSetItem: def __init__(self, name: Optional[str] = None) -> None: self.name = name self.id: Optional[str] = None - self.groups: List["GroupItem"] = [] + self.groups: list["GroupItem"] = [] self.group_count: int = 0 def __str__(self) -> str: @@ -25,13 +25,13 @@ def __repr__(self) -> str: return self.__str__() @classmethod - def from_response(cls, response: bytes, ns: Dict[str, str]) -> List["GroupSetItem"]: + def from_response(cls, response: bytes, ns: dict[str, str]) -> list["GroupSetItem"]: parsed_response = fromstring(response) all_groupset_xml = parsed_response.findall(".//t:groupSet", namespaces=ns) return [cls.from_xml(xml, ns) for xml in all_groupset_xml] @classmethod - def from_xml(cls, groupset_xml: ET.Element, ns: Dict[str, str]) -> "GroupSetItem": + def from_xml(cls, groupset_xml: ET.Element, ns: dict[str, str]) -> "GroupSetItem": def get_group(group_xml: ET.Element) -> GroupItem: group_item = GroupItem() group_item._id = group_xml.get("id") diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index 444674e19..d7cf891cc 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -1,7 +1,7 @@ from .property_decorators import property_is_valid_time, property_not_nullable -class IntervalItem(object): +class IntervalItem: class Frequency: Hourly = "Hourly" Daily = "Daily" @@ -25,7 +25,7 @@ class Day: LastDay = "LastDay" -class HourlyInterval(object): +class HourlyInterval: def __init__(self, start_time, end_time, interval_value): self.start_time = start_time self.end_time = end_time @@ -73,12 +73,12 @@ def interval(self, intervals): for interval in intervals: # if an hourly interval is a string, then it is a weekDay interval if isinstance(interval, str) and not interval.isnumeric() and not hasattr(IntervalItem.Day, interval): - error = "Invalid weekDay interval {}".format(interval) + error = f"Invalid weekDay interval {interval}" raise ValueError(error) # if an hourly interval is a number, it is an hours or minutes interval if isinstance(interval, (int, float)) and float(interval) not in VALID_INTERVALS: - error = "Invalid interval {} not in {}".format(interval, str(VALID_INTERVALS)) + error = f"Invalid interval {interval} not in {str(VALID_INTERVALS)}" raise ValueError(error) self._interval = intervals @@ -108,7 +108,7 @@ def _interval_type_pairs(self): return interval_type_pairs -class DailyInterval(object): +class DailyInterval: def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values @@ -141,12 +141,12 @@ def interval(self, intervals): for interval in intervals: # if an hourly interval is a string, then it is a weekDay interval if isinstance(interval, str) and not interval.isnumeric() and not hasattr(IntervalItem.Day, interval): - error = "Invalid weekDay interval {}".format(interval) + error = f"Invalid weekDay interval {interval}" raise ValueError(error) # if an hourly interval is a number, it is an hours or minutes interval if isinstance(interval, (int, float)) and float(interval) not in VALID_INTERVALS: - error = "Invalid interval {} not in {}".format(interval, str(VALID_INTERVALS)) + error = f"Invalid interval {interval} not in {str(VALID_INTERVALS)}" raise ValueError(error) self._interval = intervals @@ -176,7 +176,7 @@ def _interval_type_pairs(self): return interval_type_pairs -class WeeklyInterval(object): +class WeeklyInterval: def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values @@ -213,7 +213,7 @@ def _interval_type_pairs(self): return [(IntervalItem.Occurrence.WeekDay, day) for day in self.interval] -class MonthlyInterval(object): +class MonthlyInterval: def __init__(self, start_time, interval_value): self.start_time = start_time diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 155ce668b..cc7cd5811 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -1,5 +1,5 @@ import datetime -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -7,7 +7,7 @@ from tableauserverclient.models.flow_run_item import FlowRunItem -class JobItem(object): +class JobItem: class FinishCode: """ Status codes as documented on @@ -27,7 +27,7 @@ def __init__( started_at: Optional[datetime.datetime] = None, completed_at: Optional[datetime.datetime] = None, finish_code: int = 0, - notes: Optional[List[str]] = None, + notes: Optional[list[str]] = None, mode: Optional[str] = None, workbook_id: Optional[str] = None, datasource_id: Optional[str] = None, @@ -43,7 +43,7 @@ def __init__( self._started_at = started_at self._completed_at = completed_at self._finish_code = finish_code - self._notes: List[str] = notes or [] + self._notes: list[str] = notes or [] self._mode = mode self._workbook_id = workbook_id self._datasource_id = datasource_id @@ -81,7 +81,7 @@ def finish_code(self) -> int: return self._finish_code @property - def notes(self) -> List[str]: + def notes(self) -> list[str]: return self._notes @property @@ -139,7 +139,7 @@ def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" @classmethod - def from_response(cls, xml, ns) -> List["JobItem"]: + def from_response(cls, xml, ns) -> list["JobItem"]: parsed_response = fromstring(xml) all_tasks_xml = parsed_response.findall(".//t:job", namespaces=ns) @@ -191,7 +191,7 @@ def _parse_element(cls, element, ns): ) -class BackgroundJobItem(object): +class BackgroundJobItem: class Status: Pending: str = "Pending" InProgress: str = "InProgress" @@ -270,7 +270,7 @@ def priority(self) -> int: return self._priority @classmethod - def from_response(cls, xml, ns) -> List["BackgroundJobItem"]: + def from_response(cls, xml, ns) -> list["BackgroundJobItem"]: parsed_response = fromstring(xml) all_tasks_xml = parsed_response.findall(".//t:backgroundJob", namespaces=ns) return [cls._parse_element(x, ns) for x in all_tasks_xml] diff --git a/tableauserverclient/models/linked_tasks_item.py b/tableauserverclient/models/linked_tasks_item.py index ae9b60425..14a0e4978 100644 --- a/tableauserverclient/models/linked_tasks_item.py +++ b/tableauserverclient/models/linked_tasks_item.py @@ -1,5 +1,5 @@ import datetime as dt -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -14,7 +14,7 @@ def __init__(self) -> None: self.schedule: Optional[ScheduleItem] = None @classmethod - def from_response(cls, resp: bytes, namespace) -> List["LinkedTaskItem"]: + def from_response(cls, resp: bytes, namespace) -> list["LinkedTaskItem"]: parsed_response = fromstring(resp) return [ cls._parse_element(x, namespace) @@ -35,10 +35,10 @@ def __init__(self) -> None: self.id: Optional[str] = None self.step_number: Optional[int] = None self.stop_downstream_on_failure: Optional[bool] = None - self.task_details: List[LinkedTaskFlowRunItem] = [] + self.task_details: list[LinkedTaskFlowRunItem] = [] @classmethod - def from_task_xml(cls, xml, namespace) -> List["LinkedTaskStepItem"]: + def from_task_xml(cls, xml, namespace) -> list["LinkedTaskStepItem"]: return [cls._parse_element(x, namespace) for x in xml.findall(".//t:linkedTaskSteps[@id]", namespace)] @classmethod @@ -61,7 +61,7 @@ def __init__(self) -> None: self.flow_name: Optional[str] = None @classmethod - def _parse_element(cls, xml, namespace) -> List["LinkedTaskFlowRunItem"]: + def _parse_element(cls, xml, namespace) -> list["LinkedTaskFlowRunItem"]: all_tasks = [] for flow_run in xml.findall(".//t:flowRun[@id]", namespace): task = cls() diff --git a/tableauserverclient/models/metric_item.py b/tableauserverclient/models/metric_item.py index d8ba8e825..432fd861a 100644 --- a/tableauserverclient/models/metric_item.py +++ b/tableauserverclient/models/metric_item.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as ET from datetime import datetime -from typing import List, Optional, Set +from typing import Optional from tableauserverclient.datetime_helpers import parse_datetime from .property_decorators import property_is_boolean, property_is_datetime @@ -8,7 +8,7 @@ from .permissions_item import Permission -class MetricItem(object): +class MetricItem: def __init__(self, name: Optional[str] = None): self._id: Optional[str] = None self._name: Optional[str] = name @@ -21,8 +21,8 @@ def __init__(self, name: Optional[str] = None): self._project_name: Optional[str] = None self._owner_id: Optional[str] = None self._view_id: Optional[str] = None - self._initial_tags: Set[str] = set() - self.tags: Set[str] = set() + self._initial_tags: set[str] = set() + self.tags: set[str] = set() self._permissions: Optional[Permission] = None @property @@ -126,7 +126,7 @@ def from_response( cls, resp: bytes, ns, - ) -> List["MetricItem"]: + ) -> list["MetricItem"]: all_metric_items = list() parsed_response = ET.fromstring(resp) all_metric_xml = parsed_response.findall(".//t:metric", namespaces=ns) diff --git a/tableauserverclient/models/pagination_item.py b/tableauserverclient/models/pagination_item.py index 8cebd1c86..f30519be5 100644 --- a/tableauserverclient/models/pagination_item.py +++ b/tableauserverclient/models/pagination_item.py @@ -1,7 +1,7 @@ from defusedxml.ElementTree import fromstring -class PaginationItem(object): +class PaginationItem: def __init__(self): self._page_number = None self._page_size = None diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 26f4ee7e8..3e4fec22a 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -1,5 +1,5 @@ import xml.etree.ElementTree as ET -from typing import Dict, List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -47,12 +47,12 @@ def __repr__(self): class PermissionsRule: - def __init__(self, grantee: ResourceReference, capabilities: Dict[str, str]) -> None: + def __init__(self, grantee: ResourceReference, capabilities: dict[str, str]) -> None: self.grantee = grantee self.capabilities = capabilities def __repr__(self): - return "".format(self.grantee, self.capabilities) + return f"" def __eq__(self, other: object) -> bool: if not hasattr(other, "grantee") or not hasattr(other, "capabilities"): @@ -66,7 +66,7 @@ def __and__(self, other: "PermissionsRule") -> "PermissionsRule": if self.capabilities == other.capabilities: return self - capabilities = set((*self.capabilities.keys(), *other.capabilities.keys())) + capabilities = {*self.capabilities.keys(), *other.capabilities.keys()} new_capabilities = {} for capability in capabilities: if (self.capabilities.get(capability), other.capabilities.get(capability)) == ( @@ -86,7 +86,7 @@ def __or__(self, other: "PermissionsRule") -> "PermissionsRule": if self.capabilities == other.capabilities: return self - capabilities = set((*self.capabilities.keys(), *other.capabilities.keys())) + capabilities = {*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)): @@ -100,14 +100,14 @@ def __or__(self, other: "PermissionsRule") -> "PermissionsRule": return PermissionsRule(self.grantee, new_capabilities) @classmethod - def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: + def from_response(cls, resp, ns=None) -> list["PermissionsRule"]: parsed_response = fromstring(resp) rules = [] permissions_rules_list_xml = parsed_response.findall(".//t:granteeCapabilities", namespaces=ns) for grantee_capability_xml in permissions_rules_list_xml: - capability_dict: Dict[str, str] = {} + capability_dict: dict[str, str] = {} grantee = PermissionsRule._parse_grantee_element(grantee_capability_xml, ns) @@ -116,7 +116,7 @@ def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: mode = capability_xml.get("mode") if name is None or mode is None: - logger.error("Capability was not valid: {}".format(capability_xml)) + logger.error(f"Capability was not valid: {capability_xml}") raise UnpopulatedPropertyError() else: capability_dict[name] = mode @@ -127,7 +127,7 @@ def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: return rules @staticmethod - def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[Dict[str, str]]) -> ResourceReference: + def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[dict[str, str]]) -> ResourceReference: """Use Xpath magic and some string splitting to get the right object type from the xml""" # Get the first element in the tree with an 'id' attribute @@ -146,6 +146,6 @@ def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[Dict elif grantee_type == "groupSet": grantee = GroupSetItem.as_reference(grantee_id) else: - raise UnknownGranteeTypeError("No support for grantee type of {}".format(grantee_type)) + raise UnknownGranteeTypeError(f"No support for grantee type of {grantee_type}") return grantee diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 9fb382885..d875abbdf 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -1,6 +1,6 @@ import logging import xml.etree.ElementTree as ET -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -8,14 +8,14 @@ from tableauserverclient.models.property_decorators import property_is_enum, property_not_empty -class ProjectItem(object): +class ProjectItem: class ContentPermissions: LockedToProject: str = "LockedToProject" ManagedByOwner: str = "ManagedByOwner" LockedToProjectWithoutNested: str = "LockedToProjectWithoutNested" def __repr__(self): - return "".format( + return "".format( self._id, self.name, self.parent_id or "None (Top level)", self.content_permissions or "Not Set" ) @@ -158,7 +158,7 @@ def _set_permissions(self, permissions): self._permissions = permissions def _set_default_permissions(self, permissions, content_type): - attr = "_default_{content}_permissions".format(content=content_type) + attr = f"_default_{content_type}_permissions" setattr( self, attr, @@ -166,7 +166,7 @@ def _set_default_permissions(self, permissions, content_type): ) @classmethod - def from_response(cls, resp, ns) -> List["ProjectItem"]: + def from_response(cls, resp, ns) -> list["ProjectItem"]: all_project_items = list() parsed_response = fromstring(resp) all_project_xml = parsed_response.findall(".//t:project", namespaces=ns) diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index ce31b1428..5048b3498 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -1,7 +1,8 @@ import datetime import re from functools import wraps -from typing import Any, Container, Optional, Tuple +from typing import Any, Optional +from collections.abc import Container from tableauserverclient.datetime_helpers import parse_datetime @@ -11,7 +12,7 @@ def property_type_decorator(func): @wraps(func) def wrapper(self, value): if value is not None and not hasattr(enum_type, value): - error = "Invalid value: {0}. {1} must be of type {2}.".format(value, func.__name__, enum_type.__name__) + error = f"Invalid value: {value}. {func.__name__} must be of type {enum_type.__name__}." raise ValueError(error) return func(self, value) @@ -24,7 +25,7 @@ def property_is_boolean(func): @wraps(func) def wrapper(self, value): if not isinstance(value, bool): - error = "Boolean expected for {0} flag.".format(func.__name__) + error = f"Boolean expected for {func.__name__} flag." raise ValueError(error) return func(self, value) @@ -35,7 +36,7 @@ def property_not_nullable(func): @wraps(func) def wrapper(self, value): if value is None: - error = "{0} must be defined.".format(func.__name__) + error = f"{func.__name__} must be defined." raise ValueError(error) return func(self, value) @@ -46,7 +47,7 @@ def property_not_empty(func): @wraps(func) def wrapper(self, value): if not value: - error = "{0} must not be empty.".format(func.__name__) + error = f"{func.__name__} must not be empty." raise ValueError(error) return func(self, value) @@ -66,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. @@ -81,7 +82,7 @@ def property_is_int(range: Tuple[int, int], allowed: Optional[Container[Any]] = def property_type_decorator(func): @wraps(func) def wrapper(self, value): - error = "Invalid property defined: '{}'. Integer value expected.".format(value) + error = f"Invalid property defined: '{value}'. Integer value expected." if range is None: if isinstance(value, int): @@ -133,7 +134,7 @@ def wrapper(self, value): return func(self, value) if not isinstance(value, str): raise ValueError( - "Cannot convert {} into a datetime, cannot update {}".format(value.__class__.__name__, func.__name__) + f"Cannot convert {value.__class__.__name__} into a datetime, cannot update {func.__name__}" ) dt = parse_datetime(value) @@ -146,11 +147,11 @@ def property_is_data_acceleration_config(func): @wraps(func) def wrapper(self, value): if not isinstance(value, dict): - raise ValueError("{} is not type 'dict', cannot update {})".format(value.__class__.__name__, func.__name__)) + raise ValueError(f"{value.__class__.__name__} is not type 'dict', cannot update {func.__name__})") 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 = f"{func.__name__} should have 2 keys " error += "'acceleration_enabled' and 'accelerate_now'" - error += "instead you have {}".format(value.keys()) + error += f"instead you have {value.keys()}" raise ValueError(error) return func(self, value) diff --git a/tableauserverclient/models/reference_item.py b/tableauserverclient/models/reference_item.py index 710548fcc..4c1fff564 100644 --- a/tableauserverclient/models/reference_item.py +++ b/tableauserverclient/models/reference_item.py @@ -1,10 +1,10 @@ -class ResourceReference(object): +class ResourceReference: def __init__(self, id_, tag_name): self.id = id_ self.tag_name = tag_name def __str__(self): - return "".format(self._id, self._tag_name) + return f"" __repr__ = __str__ diff --git a/tableauserverclient/models/revision_item.py b/tableauserverclient/models/revision_item.py index a0e6a1bd5..1b4cc6249 100644 --- a/tableauserverclient/models/revision_item.py +++ b/tableauserverclient/models/revision_item.py @@ -1,12 +1,12 @@ from datetime import datetime -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime -class RevisionItem(object): +class RevisionItem: def __init__(self): self._resource_id: Optional[str] = None self._resource_name: Optional[str] = None @@ -56,7 +56,7 @@ def __repr__(self): ) @classmethod - def from_response(cls, resp: bytes, ns, resource_item) -> List["RevisionItem"]: + def from_response(cls, resp: bytes, ns, resource_item) -> list["RevisionItem"]: all_revision_items = list() parsed_response = fromstring(resp) all_revision_xml = parsed_response.findall(".//t:revision", namespaces=ns) diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index e416643ba..e39042058 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -19,7 +19,7 @@ Interval = Union[HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval] -class ScheduleItem(object): +class ScheduleItem: class Type: Extract = "Extract" Flow = "Flow" @@ -336,7 +336,7 @@ def parse_add_to_schedule_response(response, ns): all_task_xml = parsed_response.findall(".//t:task", namespaces=ns) error = ( - "Status {}: {}".format(response.status_code, response.reason) + f"Status {response.status_code}: {response.reason}" if response.status_code < 200 or response.status_code >= 300 else None ) diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py index 57fc51af9..5c3f6acc7 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -6,7 +6,7 @@ from tableauserverclient.helpers.logging import logger -class ServerInfoItem(object): +class ServerInfoItem: def __init__(self, product_version, build_number, rest_api_version): self._product_version = product_version self._build_number = build_number @@ -40,11 +40,11 @@ def from_response(cls, resp, ns): try: parsed_response = fromstring(resp) except xml.etree.ElementTree.ParseError as error: - logger.info("Unexpected response for ServerInfo: {}".format(resp)) + logger.info(f"Unexpected response for ServerInfo: {resp}") logger.info(error) return cls("Unknown", "Unknown", "Unknown") except Exception as error: - logger.info("Unexpected response for ServerInfo: {}".format(resp)) + logger.info(f"Unexpected response for ServerInfo: {resp}") logger.info(error) return cls("Unknown", "Unknown", "Unknown") diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index b651e5773..2d9f014a2 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -14,13 +14,13 @@ VALID_CONTENT_URL_RE = r"^[a-zA-Z0-9_\-]*$" -from typing import List, Optional, Union, TYPE_CHECKING +from typing import Optional, Union, TYPE_CHECKING if TYPE_CHECKING: from tableauserverclient.server import Server -class SiteItem(object): +class SiteItem: _user_quota: Optional[int] = None _tier_creator_capacity: Optional[int] = None _tier_explorer_capacity: Optional[int] = None @@ -873,7 +873,7 @@ def _set_values( self.auto_suspend_refresh_inactivity_window = auto_suspend_refresh_inactivity_window @classmethod - def from_response(cls, resp, ns) -> List["SiteItem"]: + def from_response(cls, resp, ns) -> list["SiteItem"]: all_site_items = list() parsed_response = fromstring(resp) all_site_xml = parsed_response.findall(".//t:site", namespaces=ns) diff --git a/tableauserverclient/models/subscription_item.py b/tableauserverclient/models/subscription_item.py index e96fcc448..61c75e2d6 100644 --- a/tableauserverclient/models/subscription_item.py +++ b/tableauserverclient/models/subscription_item.py @@ -1,4 +1,4 @@ -from typing import List, Type, TYPE_CHECKING +from typing import TYPE_CHECKING from defusedxml.ElementTree import fromstring @@ -10,7 +10,7 @@ from .target import Target -class SubscriptionItem(object): +class SubscriptionItem: def __init__(self, subject: str, schedule_id: str, user_id: str, target: "Target") -> None: self._id = None self.attach_image = True @@ -79,7 +79,7 @@ def suspended(self, value: bool) -> None: self._suspended = value @classmethod - def from_response(cls: Type, xml: bytes, ns) -> List["SubscriptionItem"]: + def from_response(cls: type, xml: bytes, ns) -> list["SubscriptionItem"]: parsed_response = fromstring(xml) all_subscriptions_xml = parsed_response.findall(".//t:subscription", namespaces=ns) diff --git a/tableauserverclient/models/table_item.py b/tableauserverclient/models/table_item.py index f9df8a8f3..0afdd4df3 100644 --- a/tableauserverclient/models/table_item.py +++ b/tableauserverclient/models/table_item.py @@ -4,7 +4,7 @@ from .property_decorators import property_not_empty, property_is_boolean -class TableItem(object): +class TableItem: def __init__(self, name, description=None): self._id = None self.description = description diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 10cf58723..c1e9d62bf 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -1,5 +1,5 @@ import abc -from typing import Dict, Optional +from typing import 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" @@ -42,7 +42,7 @@ def __init__( 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): @@ -69,7 +69,7 @@ def __init__( 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, @@ -95,7 +95,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): diff --git a/tableauserverclient/models/tableau_types.py b/tableauserverclient/models/tableau_types.py index bac072076..ea2a5e4f8 100644 --- a/tableauserverclient/models/tableau_types.py +++ b/tableauserverclient/models/tableau_types.py @@ -32,4 +32,4 @@ def plural_type(content_type: Resource) -> str: if content_type == Resource.Lens: return "lenses" else: - return "{}s".format(content_type) + return f"{content_type}s" diff --git a/tableauserverclient/models/tag_item.py b/tableauserverclient/models/tag_item.py index afa0a0762..cde755f05 100644 --- a/tableauserverclient/models/tag_item.py +++ b/tableauserverclient/models/tag_item.py @@ -1,16 +1,15 @@ import xml.etree.ElementTree as ET -from typing import Set from defusedxml.ElementTree import fromstring -class TagItem(object): +class TagItem: @classmethod - def from_response(cls, resp: bytes, ns) -> Set[str]: + def from_response(cls, resp: bytes, ns) -> set[str]: return cls.from_xml_element(fromstring(resp), ns) @classmethod - def from_xml_element(cls, parsed_response: ET.Element, ns) -> Set[str]: + def from_xml_element(cls, parsed_response: ET.Element, ns) -> set[str]: all_tags = set() tag_elem = parsed_response.findall(".//t:tag", namespaces=ns) for tag_xml in tag_elem: diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index 01cfcfb11..fa6f782ba 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -8,7 +8,7 @@ from tableauserverclient.models.target import Target -class TaskItem(object): +class TaskItem: class Type: ExtractRefresh = "extractRefresh" DataAcceleration = "dataAcceleration" @@ -48,9 +48,9 @@ def __repr__(self) -> str: ) @classmethod - def from_response(cls, xml, ns, task_type=Type.ExtractRefresh) -> List["TaskItem"]: + def from_response(cls, xml, ns, task_type=Type.ExtractRefresh) -> list["TaskItem"]: parsed_response = fromstring(xml) - all_tasks_xml = parsed_response.findall(".//t:task/t:{}".format(task_type), namespaces=ns) + all_tasks_xml = parsed_response.findall(f".//t:task/t:{task_type}", namespaces=ns) all_tasks = (TaskItem._parse_element(x, ns) for x in all_tasks_xml) diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index fe659575a..fb29492e4 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -2,7 +2,7 @@ import xml.etree.ElementTree as ET from datetime import datetime from enum import IntEnum -from typing import Dict, List, Optional, TYPE_CHECKING, Tuple +from typing import Optional, TYPE_CHECKING from defusedxml.ElementTree import fromstring @@ -18,7 +18,7 @@ from tableauserverclient.server import Pager -class UserItem(object): +class UserItem: tag_name: str = "user" class Roles: @@ -57,7 +57,7 @@ def __init__( self._id: Optional[str] = None self._last_login: Optional[datetime] = None self._workbooks = None - self._favorites: Optional[Dict[str, List]] = None + self._favorites: Optional[dict[str, list]] = None self._groups = None self.email: Optional[str] = None self.fullname: Optional[str] = None @@ -69,7 +69,7 @@ def __init__( def __str__(self) -> str: str_site_role = self.site_role or "None" - return "".format(self.id, self.name, str_site_role) + return f"" def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" @@ -141,7 +141,7 @@ def workbooks(self) -> "Pager": return self._workbooks() @property - def favorites(self) -> Dict[str, List]: + def favorites(self) -> dict[str, list]: if self._favorites is None: error = "User item must be populated with favorites first." raise UnpopulatedPropertyError(error) @@ -210,12 +210,12 @@ def _set_values( self._domain_name = domain_name @classmethod - def from_response(cls, resp, ns) -> List["UserItem"]: + def from_response(cls, resp, ns) -> list["UserItem"]: element_name = ".//t:user" return cls._parse_xml(element_name, resp, ns) @classmethod - def from_response_as_owner(cls, resp, ns) -> List["UserItem"]: + def from_response_as_owner(cls, resp, ns) -> list["UserItem"]: element_name = ".//t:owner" return cls._parse_xml(element_name, resp, ns) @@ -283,7 +283,7 @@ def _parse_element(user_xml, ns): domain_name, ) - class CSVImport(object): + class CSVImport: """ This class includes hardcoded options and logic for the CSV file format defined for user import https://help.tableau.com/current/server/en-us/users_import.htm @@ -308,7 +308,7 @@ def create_user_from_line(line: str): if line is None or line is False or line == "\n" or line == "": return None line = line.strip().lower() - values: List[str] = list(map(str.strip, line.split(","))) + values: list[str] = list(map(str.strip, line.split(","))) user = UserItem(values[UserItem.CSVImport.ColumnType.USERNAME]) if len(values) > 1: if len(values) > UserItem.CSVImport.ColumnType.MAX: @@ -337,7 +337,7 @@ def create_user_from_line(line: str): # Read through an entire CSV file meant for user import # Return the number of valid lines and a list of all the invalid lines @staticmethod - def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> Tuple[int, List[str]]: + def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> tuple[int, list[str]]: num_valid_lines = 0 invalid_lines = [] csv_file.seek(0) # set to start of file in case it has been read earlier @@ -345,11 +345,11 @@ def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> Tuple[int, L while line and line != "": try: # do not print passwords - logger.info("Reading user {}".format(line[:4])) + logger.info(f"Reading user {line[:4]}") UserItem.CSVImport._validate_import_line_or_throw(line, logger) num_valid_lines += 1 except Exception as exc: - logger.info("Error parsing {}: {}".format(line[:4], exc)) + logger.info(f"Error parsing {line[:4]}: {exc}") invalid_lines.append(line) line = csv_file.readline() return num_valid_lines, invalid_lines @@ -358,7 +358,7 @@ def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> Tuple[int, L # Iterate through each field and validate the given value against hardcoded constraints @staticmethod def _validate_import_line_or_throw(incoming, logger) -> None: - _valid_attributes: List[List[str]] = [ + _valid_attributes: list[list[str]] = [ [], [], [], @@ -373,23 +373,23 @@ def _validate_import_line_or_throw(incoming, logger) -> None: if len(line) > UserItem.CSVImport.ColumnType.MAX: raise AttributeError("Too many attributes in line") username = line[UserItem.CSVImport.ColumnType.USERNAME.value] - logger.debug("> details - {}".format(username)) + logger.debug(f"> details - {username}") UserItem.validate_username_or_throw(username) for i in range(1, len(line)): - logger.debug("column {}: {}".format(UserItem.CSVImport.ColumnType(i).name, line[i])) + logger.debug(f"column {UserItem.CSVImport.ColumnType(i).name}: {line[i]}") UserItem.CSVImport._validate_attribute_value( line[i], _valid_attributes[i], UserItem.CSVImport.ColumnType(i) ) # Given a restricted set of possible values, confirm the item is in that set @staticmethod - def _validate_attribute_value(item: str, possible_values: List[str], column_type) -> None: + def _validate_attribute_value(item: str, possible_values: list[str], column_type) -> None: if item is None or item == "": # value can be empty for any column except user, which is checked elsewhere return if item in possible_values or possible_values == []: return - raise AttributeError("Invalid value {} for {}".format(item, column_type)) + raise AttributeError(f"Invalid value {item} for {column_type}") # https://help.tableau.com/current/server/en-us/csvguidelines.htm#settings_and_site_roles # This logic is hardcoded to match the existing rules for import csv files diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index a26e364a3..dc5f37a48 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -1,7 +1,8 @@ import copy from datetime import datetime from requests import Response -from typing import Callable, Iterator, List, Optional, Set +from typing import Callable, Optional +from collections.abc import Iterator from defusedxml.ElementTree import fromstring @@ -11,13 +12,13 @@ from .tag_item import TagItem -class ViewItem(object): +class ViewItem: def __init__(self) -> None: self._content_url: Optional[str] = None self._created_at: Optional[datetime] = None self._id: Optional[str] = None self._image: Optional[Callable[[], bytes]] = None - self._initial_tags: Set[str] = set() + self._initial_tags: set[str] = set() self._name: Optional[str] = None self._owner_id: Optional[str] = None self._preview_image: Optional[Callable[[], bytes]] = None @@ -29,15 +30,15 @@ def __init__(self) -> None: self._sheet_type: Optional[str] = None self._updated_at: Optional[datetime] = None self._workbook_id: Optional[str] = None - self._permissions: Optional[Callable[[], List[PermissionsRule]]] = None - self.tags: Set[str] = set() + 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( + return "".format( self._id, self.name, self.content_url, self.project_id ) @@ -146,21 +147,21 @@ def data_acceleration_config(self, value): self._data_acceleration_config = value @property - def permissions(self) -> List[PermissionsRule]: + def permissions(self) -> list[PermissionsRule]: if self._permissions is None: error = "View item must be populated with permissions first." raise UnpopulatedPropertyError(error) return self._permissions() - def _set_permissions(self, permissions: Callable[[], List[PermissionsRule]]) -> None: + def _set_permissions(self, permissions: Callable[[], list[PermissionsRule]]) -> None: self._permissions = permissions @classmethod - def from_response(cls, resp: "Response", ns, workbook_id="") -> List["ViewItem"]: + def from_response(cls, resp: "Response", ns, workbook_id="") -> list["ViewItem"]: return cls.from_xml_element(fromstring(resp), ns, workbook_id) @classmethod - def from_xml_element(cls, parsed_response, ns, workbook_id="") -> List["ViewItem"]: + def from_xml_element(cls, parsed_response, ns, workbook_id="") -> list["ViewItem"]: all_view_items = list() all_view_xml = parsed_response.findall(".//t:view", namespaces=ns) for view_xml in all_view_xml: diff --git a/tableauserverclient/models/virtual_connection_item.py b/tableauserverclient/models/virtual_connection_item.py index 76a3b5dea..e9e22be1e 100644 --- a/tableauserverclient/models/virtual_connection_item.py +++ b/tableauserverclient/models/virtual_connection_item.py @@ -1,6 +1,7 @@ import datetime as dt import json -from typing import Callable, Dict, Iterable, List, Optional +from typing import Callable, Optional +from collections.abc import Iterable from xml.etree.ElementTree import Element from defusedxml.ElementTree import fromstring @@ -23,7 +24,7 @@ def __init__(self, name: str) -> None: self._connections: Optional[Callable[[], Iterable[ConnectionItem]]] = None self.project_id: Optional[str] = None self.owner_id: Optional[str] = None - self.content: Optional[Dict[str, dict]] = None + self.content: Optional[dict[str, dict]] = None self.certification_note: Optional[str] = None def __str__(self) -> str: @@ -40,7 +41,7 @@ def id(self) -> Optional[str]: return self._id @property - def permissions(self) -> List[PermissionsRule]: + def permissions(self) -> list[PermissionsRule]: if self._permissions is None: error = "Workbook item must be populated with permissions first." raise UnpopulatedPropertyError(error) @@ -53,12 +54,12 @@ def connections(self) -> Iterable[ConnectionItem]: return self._connections() @classmethod - def from_response(cls, response: bytes, ns: Dict[str, str]) -> List["VirtualConnectionItem"]: + def from_response(cls, response: bytes, ns: dict[str, str]) -> list["VirtualConnectionItem"]: parsed_response = fromstring(response) return [cls.from_xml(xml, ns) for xml in parsed_response.findall(".//t:virtualConnection[@name]", ns)] @classmethod - def from_xml(cls, xml: Element, ns: Dict[str, str]) -> "VirtualConnectionItem": + def from_xml(cls, xml: Element, ns: dict[str, str]) -> "VirtualConnectionItem": v_conn = cls(xml.get("name", "")) v_conn._id = xml.get("id", None) v_conn.webpage_url = xml.get("webpageUrl", None) diff --git a/tableauserverclient/models/webhook_item.py b/tableauserverclient/models/webhook_item.py index e4d5e4aa0..98d821fb4 100644 --- a/tableauserverclient/models/webhook_item.py +++ b/tableauserverclient/models/webhook_item.py @@ -1,6 +1,6 @@ import re import xml.etree.ElementTree as ET -from typing import List, Optional, Tuple, Type +from typing import Optional from defusedxml.ElementTree import fromstring @@ -13,7 +13,7 @@ def _parse_event(events): return NAMESPACE_RE.sub("", event.tag) -class WebhookItem(object): +class WebhookItem: def __init__(self): self._id: Optional[str] = None self.name: Optional[str] = None @@ -45,10 +45,10 @@ def event(self) -> Optional[str]: @event.setter def event(self, value: str) -> None: - self._event = "webhook-source-event-{}".format(value) + self._event = f"webhook-source-event-{value}" @classmethod - def from_response(cls: Type["WebhookItem"], resp: bytes, ns) -> List["WebhookItem"]: + def from_response(cls: type["WebhookItem"], resp: bytes, ns) -> list["WebhookItem"]: all_webhooks_items = list() parsed_response = fromstring(resp) all_webhooks_xml = parsed_response.findall(".//t:webhook", namespaces=ns) @@ -61,7 +61,7 @@ def from_response(cls: Type["WebhookItem"], resp: bytes, ns) -> List["WebhookIte return all_webhooks_items @staticmethod - def _parse_element(webhook_xml: ET.Element, ns) -> Tuple: + def _parse_element(webhook_xml: ET.Element, ns) -> tuple: id = webhook_xml.get("id", None) name = webhook_xml.get("name", None) @@ -82,4 +82,4 @@ def _parse_element(webhook_xml: ET.Element, ns) -> Tuple: return id, name, url, event, owner_id def __repr__(self) -> str: - return "".format(self.id, self.name, self.url, self.event) + return f"" diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 58fd2a9a9..ab5ff4157 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -2,7 +2,7 @@ import datetime import uuid import xml.etree.ElementTree as ET -from typing import Callable, Dict, List, Optional, Set +from typing import Callable, Optional from defusedxml.ElementTree import fromstring @@ -20,7 +20,7 @@ from .data_freshness_policy_item import DataFreshnessPolicyItem -class WorkbookItem(object): +class WorkbookItem: def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, show_tabs: bool = False) -> None: self._connections = None self._content_url = None @@ -35,15 +35,15 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, self._revisions = None self._size = None self._updated_at = None - self._views: Optional[Callable[[], List[ViewItem]]] = None + self._views: Optional[Callable[[], list[ViewItem]]] = None self.name = name self._description = None self.owner_id: Optional[str] = None # workaround for Personal Space workbooks without a project self.project_id: Optional[str] = project_id or uuid.uuid4().__str__() self.show_tabs = show_tabs - self.hidden_views: Optional[List[str]] = None - self.tags: Set[str] = set() + self.hidden_views: Optional[list[str]] = None + self.tags: set[str] = set() self.data_acceleration_config = { "acceleration_enabled": None, "accelerate_now": None, @@ -56,7 +56,7 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, return None def __str__(self): - return "".format( + return "".format( self._id, self.name, self.content_url, self.project_id ) @@ -64,14 +64,14 @@ def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" @property - def connections(self) -> List[ConnectionItem]: + def connections(self) -> list[ConnectionItem]: if self._connections is None: error = "Workbook item must be populated with connections first." raise UnpopulatedPropertyError(error) return self._connections() @property - def permissions(self) -> List[PermissionsRule]: + def permissions(self) -> list[PermissionsRule]: if self._permissions is None: error = "Workbook item must be populated with permissions first." raise UnpopulatedPropertyError(error) @@ -152,7 +152,7 @@ def updated_at(self) -> Optional[datetime.datetime]: return self._updated_at @property - def views(self) -> List[ViewItem]: + def views(self) -> list[ViewItem]: # Views can be set in an initial workbook response OR by a call # to Server. Without getting too fancy, I think we can rely on # returning a list from the response, until they call @@ -191,7 +191,7 @@ def data_freshness_policy(self, value): self._data_freshness_policy = value @property - def revisions(self) -> List[RevisionItem]: + def revisions(self) -> list[RevisionItem]: if self._revisions is None: error = "Workbook item must be populated with revisions first." raise UnpopulatedPropertyError(error) @@ -203,7 +203,7 @@ def _set_connections(self, connections): def _set_permissions(self, permissions): self._permissions = permissions - def _set_views(self, views: Callable[[], List[ViewItem]]) -> None: + def _set_views(self, views: Callable[[], list[ViewItem]]) -> None: self._views = views def _set_pdf(self, pdf: Callable[[], bytes]) -> None: @@ -316,7 +316,7 @@ def _set_values( self.data_freshness_policy = data_freshness_policy @classmethod - def from_response(cls, resp: str, ns: Dict[str, str]) -> List["WorkbookItem"]: + def from_response(cls, resp: str, ns: dict[str, str]) -> list["WorkbookItem"]: all_workbook_items = list() parsed_response = fromstring(resp) all_workbook_xml = parsed_response.findall(".//t:workbook", namespaces=ns) diff --git a/tableauserverclient/namespace.py b/tableauserverclient/namespace.py index d225ecff6..54ac46d8d 100644 --- a/tableauserverclient/namespace.py +++ b/tableauserverclient/namespace.py @@ -11,7 +11,7 @@ class UnknownNamespaceError(Exception): pass -class Namespace(object): +class Namespace: def __init__(self): self._namespace = {"t": NEW_NAMESPACE} self._detected = False diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 468d469a7..231052f73 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -16,7 +16,7 @@ class Auth(Endpoint): - class contextmgr(object): + class contextmgr: def __init__(self, callback): self._callback = callback @@ -28,7 +28,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): @property def baseurl(self) -> str: - return "{0}/auth".format(self.parent_srv.baseurl) + return f"{self.parent_srv.baseurl}/auth" @api(version="2.0") def sign_in(self, auth_req: "Credentials") -> contextmgr: @@ -42,7 +42,7 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr: Creates a context manager that will sign out of the server upon exit. """ - url = "{0}/{1}".format(self.baseurl, "signin") + url = f"{self.baseurl}/signin" signin_req = RequestFactory.Auth.signin_req(auth_req) server_response = self.parent_srv.session.post( url, data=signin_req, **self.parent_srv.http_options, allow_redirects=False @@ -63,7 +63,7 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr: user_id = parsed_response.find(".//t:user", namespaces=self.parent_srv.namespace).get("id", None) auth_token = parsed_response.find("t:credentials", namespaces=self.parent_srv.namespace).get("token", None) self.parent_srv._set_auth(site_id, user_id, auth_token) - logger.info("Signed into {0} as user with id {1}".format(self.parent_srv.server_address, user_id)) + logger.info(f"Signed into {self.parent_srv.server_address} as user with id {user_id}") return Auth.contextmgr(self.sign_out) # We use the same request that username/password login uses for all auth types. @@ -78,7 +78,7 @@ def sign_in_with_json_web_token(self, auth_req: "Credentials") -> contextmgr: @api(version="2.0") def sign_out(self) -> None: - url = "{0}/{1}".format(self.baseurl, "signout") + url = f"{self.baseurl}/signout" # If there are no auth tokens you're already signed out. No-op if not self.parent_srv.is_signed_in(): return @@ -88,7 +88,7 @@ def sign_out(self) -> None: @api(version="2.6") def switch_site(self, site_item: "SiteItem") -> contextmgr: - url = "{0}/{1}".format(self.baseurl, "switchSite") + url = f"{self.baseurl}/switchSite" switch_req = RequestFactory.Auth.switch_req(site_item.content_url) try: server_response = self.post_request(url, switch_req) @@ -104,11 +104,11 @@ def switch_site(self, site_item: "SiteItem") -> contextmgr: user_id = parsed_response.find(".//t:user", namespaces=self.parent_srv.namespace).get("id", None) auth_token = parsed_response.find("t:credentials", namespaces=self.parent_srv.namespace).get("token", None) self.parent_srv._set_auth(site_id, user_id, auth_token) - logger.info("Signed into {0} as user with id {1}".format(self.parent_srv.server_address, user_id)) + logger.info(f"Signed into {self.parent_srv.server_address} as user with id {user_id}") return Auth.contextmgr(self.sign_out) @api(version="3.10") def revoke_all_server_admin_tokens(self) -> None: - url = "{0}/{1}".format(self.baseurl, "revokeAllServerAdminTokens") + url = f"{self.baseurl}/revokeAllServerAdminTokens" self.post_request(url, "") logger.info("Revoked all tokens for all server admins") diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index 57a5b0100..baed91149 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -2,7 +2,7 @@ import logging import os from pathlib import Path -from typing import List, Optional, Tuple, Union +from typing import Optional, Union from tableauserverclient.config import BYTES_PER_MB, FILESIZE_LIMIT_MB from tableauserverclient.filesys_helpers import get_file_object_size @@ -33,11 +33,11 @@ class CustomViews(QuerysetEndpoint[CustomViewItem]): def __init__(self, parent_srv): - super(CustomViews, self).__init__(parent_srv) + super().__init__(parent_srv) @property def baseurl(self) -> str: - return "{0}/sites/{1}/customviews".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/customviews" @property def expurl(self) -> str: @@ -55,7 +55,7 @@ def expurl(self) -> str: """ @api(version="3.18") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[CustomViewItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[CustomViewItem], PaginationItem]: logger.info("Querying all custom views on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -68,8 +68,8 @@ def get_by_id(self, view_id: str) -> Optional[CustomViewItem]: if not view_id: error = "Custom view item missing ID." raise MissingRequiredFieldError(error) - logger.info("Querying custom view (ID: {0})".format(view_id)) - url = "{0}/{1}".format(self.baseurl, view_id) + logger.info(f"Querying custom view (ID: {view_id})") + url = f"{self.baseurl}/{view_id}" server_response = self.get_request(url) return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace) @@ -83,10 +83,10 @@ def image_fetcher(): return self._get_view_image(view_item, req_options) view_item._set_image(image_fetcher) - logger.info("Populated image for custom view (ID: {0})".format(view_item.id)) + logger.info(f"Populated image for custom view (ID: {view_item.id})") def _get_view_image(self, view_item: CustomViewItem, req_options: Optional["ImageRequestOptions"]) -> bytes: - url = "{0}/{1}/image".format(self.baseurl, view_item.id) + url = f"{self.baseurl}/{view_item.id}/image" server_response = self.get_request(url, req_options) image = server_response.content return image @@ -105,10 +105,10 @@ def update(self, view_item: CustomViewItem) -> Optional[CustomViewItem]: return view_item # Update the custom view owner or name - url = "{0}/{1}".format(self.baseurl, view_item.id) + url = f"{self.baseurl}/{view_item.id}" update_req = RequestFactory.CustomView.update_req(view_item) server_response = self.put_request(url, update_req) - logger.info("Updated custom view (ID: {0})".format(view_item.id)) + logger.info(f"Updated custom view (ID: {view_item.id})") return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace) # Delete 1 view by id @@ -117,9 +117,9 @@ def delete(self, view_id: str) -> None: if not view_id: error = "Custom View ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, view_id) + url = f"{self.baseurl}/{view_id}" self.delete_request(url) - logger.info("Deleted single custom view (ID: {0})".format(view_id)) + logger.info(f"Deleted single custom view (ID: {view_id})") @api(version="3.21") def download(self, view_item: CustomViewItem, file: PathOrFileW) -> PathOrFileW: diff --git a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py index 256a6e766..579001156 100644 --- a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py +++ b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py @@ -10,14 +10,14 @@ class DataAccelerationReport(Endpoint): def __init__(self, parent_srv): - super(DataAccelerationReport, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl) @property def baseurl(self): - return "{0}/sites/{1}/dataAccelerationReport".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/dataAccelerationReport" @api(version="3.8") def get(self, req_options=None): diff --git a/tableauserverclient/server/endpoint/data_alert_endpoint.py b/tableauserverclient/server/endpoint/data_alert_endpoint.py index fd02d2e4a..ba3ecd74f 100644 --- a/tableauserverclient/server/endpoint/data_alert_endpoint.py +++ b/tableauserverclient/server/endpoint/data_alert_endpoint.py @@ -7,7 +7,7 @@ from tableauserverclient.helpers.logging import logger -from typing import List, Optional, TYPE_CHECKING, Tuple, Union +from typing import Optional, TYPE_CHECKING, Union if TYPE_CHECKING: @@ -17,14 +17,14 @@ class DataAlerts(Endpoint): def __init__(self, parent_srv: "Server") -> None: - super(DataAlerts, self).__init__(parent_srv) + super().__init__(parent_srv) @property def baseurl(self) -> str: - return "{0}/sites/{1}/dataAlerts".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/dataAlerts" @api(version="3.2") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[DataAlertItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[DataAlertItem], PaginationItem]: logger.info("Querying all dataAlerts on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -38,8 +38,8 @@ def get_by_id(self, dataAlert_id: str) -> DataAlertItem: if not dataAlert_id: error = "dataAlert ID undefined." raise ValueError(error) - logger.info("Querying single dataAlert (ID: {0})".format(dataAlert_id)) - url = "{0}/{1}".format(self.baseurl, dataAlert_id) + logger.info(f"Querying single dataAlert (ID: {dataAlert_id})") + url = f"{self.baseurl}/{dataAlert_id}" server_response = self.get_request(url) return DataAlertItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -55,9 +55,9 @@ def delete(self, dataAlert: Union[DataAlertItem, str]) -> None: error = "Dataalert ID undefined." raise ValueError(error) # DELETE /api/api-version/sites/site-id/dataAlerts/data-alert-id/users/user-id - url = "{0}/{1}".format(self.baseurl, dataAlert_id) + url = f"{self.baseurl}/{dataAlert_id}" self.delete_request(url) - logger.info("Deleted single dataAlert (ID: {0})".format(dataAlert_id)) + logger.info(f"Deleted single dataAlert (ID: {dataAlert_id})") @api(version="3.2") def delete_user_from_alert(self, dataAlert: Union[DataAlertItem, str], user: Union[UserItem, str]) -> None: @@ -80,9 +80,9 @@ def delete_user_from_alert(self, dataAlert: Union[DataAlertItem, str], user: Uni error = "User ID undefined." raise ValueError(error) # DELETE /api/api-version/sites/site-id/dataAlerts/data-alert-id/users/user-id - url = "{0}/{1}/users/{2}".format(self.baseurl, dataAlert_id, user_id) + url = f"{self.baseurl}/{dataAlert_id}/users/{user_id}" self.delete_request(url) - logger.info("Deleted User (ID {0}) from dataAlert (ID: {1})".format(user_id, dataAlert_id)) + logger.info(f"Deleted User (ID {user_id}) from dataAlert (ID: {dataAlert_id})") @api(version="3.2") def add_user_to_alert(self, dataAlert_item: DataAlertItem, user: Union[UserItem, str]) -> UserItem: @@ -98,10 +98,10 @@ def add_user_to_alert(self, dataAlert_item: DataAlertItem, user: Union[UserItem, if not user_id: error = "User ID undefined." raise ValueError(error) - url = "{0}/{1}/users".format(self.baseurl, dataAlert_item.id) + url = f"{self.baseurl}/{dataAlert_item.id}/users" update_req = RequestFactory.DataAlert.add_user_to_alert(dataAlert_item, user_id) server_response = self.post_request(url, update_req) - logger.info("Added user (ID {0}) to dataAlert item (ID: {1})".format(user_id, dataAlert_item.id)) + logger.info(f"Added user (ID {user_id}) to dataAlert item (ID: {dataAlert_item.id})") added_user = UserItem.from_response(server_response.content, self.parent_srv.namespace)[0] return added_user @@ -111,9 +111,9 @@ def update(self, dataAlert_item: DataAlertItem) -> DataAlertItem: error = "Dataalert item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}".format(self.baseurl, dataAlert_item.id) + url = f"{self.baseurl}/{dataAlert_item.id}" update_req = RequestFactory.DataAlert.update_req(dataAlert_item) server_response = self.put_request(url, update_req) - logger.info("Updated dataAlert item (ID: {0})".format(dataAlert_item.id)) + logger.info(f"Updated dataAlert item (ID: {dataAlert_item.id})") updated_dataAlert = DataAlertItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_dataAlert diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index 2f8fece07..c0e106eb2 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -1,5 +1,6 @@ import logging -from typing import Union, Iterable, Set +from typing import Union +from collections.abc import Iterable from tableauserverclient.server.endpoint.default_permissions_endpoint import _DefaultPermissionsEndpoint from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint @@ -15,7 +16,7 @@ class Databases(Endpoint, TaggingMixin): def __init__(self, parent_srv): - super(Databases, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl) @@ -23,7 +24,7 @@ def __init__(self, parent_srv): @property def baseurl(self): - return "{0}/sites/{1}/databases".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/databases" @api(version="3.5") def get(self, req_options=None): @@ -40,8 +41,8 @@ def get_by_id(self, database_id): if not database_id: error = "database ID undefined." raise ValueError(error) - logger.info("Querying single database (ID: {0})".format(database_id)) - url = "{0}/{1}".format(self.baseurl, database_id) + logger.info(f"Querying single database (ID: {database_id})") + url = f"{self.baseurl}/{database_id}" server_response = self.get_request(url) return DatabaseItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -50,9 +51,9 @@ def delete(self, database_id): if not database_id: error = "Database ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, database_id) + url = f"{self.baseurl}/{database_id}" self.delete_request(url) - logger.info("Deleted single database (ID: {0})".format(database_id)) + logger.info(f"Deleted single database (ID: {database_id})") @api(version="3.5") def update(self, database_item): @@ -60,10 +61,10 @@ def update(self, database_item): error = "Database item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}".format(self.baseurl, database_item.id) + url = f"{self.baseurl}/{database_item.id}" update_req = RequestFactory.Database.update_req(database_item) server_response = self.put_request(url, update_req) - logger.info("Updated database item (ID: {0})".format(database_item.id)) + logger.info(f"Updated database item (ID: {database_item.id})") updated_database = DatabaseItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_database @@ -78,10 +79,10 @@ def column_fetcher(): return self._get_tables_for_database(database_item) database_item._set_tables(column_fetcher) - logger.info("Populated tables for database (ID: {0}".format(database_item.id)) + logger.info(f"Populated tables for database (ID: {database_item.id}") def _get_tables_for_database(self, database_item): - url = "{0}/{1}/tables".format(self.baseurl, database_item.id) + url = f"{self.baseurl}/{database_item.id}/tables" server_response = self.get_request(url) tables = TableItem.from_response(server_response.content, self.parent_srv.namespace) return tables @@ -127,7 +128,7 @@ def delete_dqw(self, item): self._data_quality_warnings.clear(item) @api(version="3.9") - def add_tags(self, item: Union[DatabaseItem, str], tags: Iterable[str]) -> Set[str]: + def add_tags(self, item: Union[DatabaseItem, str], tags: Iterable[str]) -> set[str]: return super().add_tags(item, tags) @api(version="3.9") diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 7f3a47075..38ef50751 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -6,7 +6,8 @@ from contextlib import closing from pathlib import Path -from typing import Iterable, List, Mapping, Optional, Sequence, Set, Tuple, TYPE_CHECKING, Union +from typing import Optional, TYPE_CHECKING, Union +from collections.abc import Iterable, Mapping, Sequence from tableauserverclient.helpers.headers import fix_filename from tableauserverclient.server.query import QuerySet @@ -57,7 +58,7 @@ class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]): def __init__(self, parent_srv: "Server") -> None: - super(Datasources, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "datasource") @@ -65,11 +66,11 @@ def __init__(self, parent_srv: "Server") -> None: @property def baseurl(self) -> str: - return "{0}/sites/{1}/datasources".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/datasources" # Get all datasources @api(version="2.0") - def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[DatasourceItem], PaginationItem]: + def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[DatasourceItem], PaginationItem]: logger.info("Querying all datasources on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -83,8 +84,8 @@ def get_by_id(self, datasource_id: str) -> DatasourceItem: if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) - logger.info("Querying single datasource (ID: {0})".format(datasource_id)) - url = "{0}/{1}".format(self.baseurl, datasource_id) + logger.info(f"Querying single datasource (ID: {datasource_id})") + url = f"{self.baseurl}/{datasource_id}" server_response = self.get_request(url) return DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -99,10 +100,10 @@ def connections_fetcher(): return self._get_datasource_connections(datasource_item) datasource_item._set_connections(connections_fetcher) - logger.info("Populated connections for datasource (ID: {0})".format(datasource_item.id)) + logger.info(f"Populated connections for datasource (ID: {datasource_item.id})") def _get_datasource_connections(self, datasource_item, req_options=None): - url = "{0}/{1}/connections".format(self.baseurl, datasource_item.id) + url = f"{self.baseurl}/{datasource_item.id}/connections" server_response = self.get_request(url, req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) return connections @@ -113,9 +114,9 @@ def delete(self, datasource_id: str) -> None: if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, datasource_id) + url = f"{self.baseurl}/{datasource_id}" self.delete_request(url) - logger.info("Deleted single datasource (ID: {0})".format(datasource_id)) + logger.info(f"Deleted single datasource (ID: {datasource_id})") # Download 1 datasource by id @api(version="2.0") @@ -152,11 +153,11 @@ def update(self, datasource_item: DatasourceItem) -> DatasourceItem: self.update_tags(datasource_item) # Update the datasource itself - url = "{0}/{1}".format(self.baseurl, datasource_item.id) + url = f"{self.baseurl}/{datasource_item.id}" update_req = RequestFactory.Datasource.update_req(datasource_item) server_response = self.put_request(url, update_req) - logger.info("Updated datasource item (ID: {0})".format(datasource_item.id)) + logger.info(f"Updated datasource item (ID: {datasource_item.id})") updated_datasource = copy.copy(datasource_item) return updated_datasource._parse_common_elements(server_response.content, self.parent_srv.namespace) @@ -165,7 +166,7 @@ def update(self, datasource_item: DatasourceItem) -> DatasourceItem: def update_connection( self, datasource_item: DatasourceItem, connection_item: ConnectionItem ) -> Optional[ConnectionItem]: - url = "{0}/{1}/connections/{2}".format(self.baseurl, datasource_item.id, connection_item.id) + url = f"{self.baseurl}/{datasource_item.id}/connections/{connection_item.id}" update_req = RequestFactory.Connection.update_req(connection_item) server_response = self.put_request(url, update_req) @@ -174,18 +175,16 @@ def update_connection( return None if len(connections) > 1: - logger.debug("Multiple connections returned ({0})".format(len(connections))) + logger.debug(f"Multiple connections returned ({len(connections)})") connection = list(filter(lambda x: x.id == connection_item.id, connections))[0] - logger.info( - "Updated datasource item (ID: {0} & connection item {1}".format(datasource_item.id, connection_item.id) - ) + logger.info(f"Updated datasource item (ID: {datasource_item.id} & connection item {connection_item.id}") return connection @api(version="2.8") def refresh(self, datasource_item: DatasourceItem) -> JobItem: id_ = getattr(datasource_item, "id", datasource_item) - url = "{0}/{1}/refresh".format(self.baseurl, id_) + url = f"{self.baseurl}/{id_}/refresh" empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -194,7 +193,7 @@ def refresh(self, datasource_item: DatasourceItem) -> JobItem: @api(version="3.5") def create_extract(self, datasource_item: DatasourceItem, encrypt: bool = False) -> JobItem: id_ = getattr(datasource_item, "id", datasource_item) - url = "{0}/{1}/createExtract?encrypt={2}".format(self.baseurl, id_, encrypt) + url = f"{self.baseurl}/{id_}/createExtract?encrypt={encrypt}" empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -203,7 +202,7 @@ def create_extract(self, datasource_item: DatasourceItem, encrypt: bool = False) @api(version="3.5") def delete_extract(self, datasource_item: DatasourceItem) -> None: id_ = getattr(datasource_item, "id", datasource_item) - url = "{0}/{1}/deleteExtract".format(self.baseurl, id_) + url = f"{self.baseurl}/{id_}/deleteExtract" empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) @@ -223,12 +222,12 @@ def publish( if isinstance(file, (os.PathLike, str)): if not os.path.isfile(file): error = "File path does not lead to an existing file." - raise IOError(error) + raise OSError(error) filename = os.path.basename(file) file_extension = os.path.splitext(filename)[1][1:] file_size = os.path.getsize(file) - logger.debug("Publishing file `{}`, size `{}`".format(filename, file_size)) + logger.debug(f"Publishing file `{filename}`, size `{file_size}`") # If name is not defined, grab the name from the file to publish if not datasource_item.name: datasource_item.name = os.path.splitext(filename)[0] @@ -247,10 +246,10 @@ def publish( elif file_type == "xml": file_extension = "tds" else: - error = "Unsupported file type {}".format(file_type) + error = f"Unsupported file type {file_type}" raise ValueError(error) - filename = "{}.{}".format(datasource_item.name, file_extension) + filename = f"{datasource_item.name}.{file_extension}" file_size = get_file_object_size(file) else: @@ -261,12 +260,12 @@ def publish( raise ValueError(error) # Construct the url with the defined mode - url = "{0}?datasourceType={1}".format(self.baseurl, file_extension) + url = f"{self.baseurl}?datasourceType={file_extension}" if mode == self.parent_srv.PublishMode.Overwrite or mode == self.parent_srv.PublishMode.Append: - url += "&{0}=true".format(mode.lower()) + url += f"&{mode.lower()}=true" if as_job: - url += "&{0}=true".format("asJob") + url += "&{}=true".format("asJob") # Determine if chunking is required (64MB is the limit for single upload method) if file_size >= FILESIZE_LIMIT_MB * BYTES_PER_MB: @@ -276,12 +275,12 @@ def publish( ) ) upload_session_id = self.parent_srv.fileuploads.upload(file) - url = "{0}&uploadSessionId={1}".format(url, upload_session_id) + url = f"{url}&uploadSessionId={upload_session_id}" xml_request, content_type = RequestFactory.Datasource.publish_req_chunked( datasource_item, connection_credentials, connections ) else: - logger.info("Publishing {0} to server".format(filename)) + logger.info(f"Publishing {filename} to server") if isinstance(file, (Path, str)): with open(file, "rb") as f: @@ -309,11 +308,11 @@ def publish( if as_job: new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Published {0} (JOB_ID: {1}".format(filename, new_job.id)) + logger.info(f"Published {filename} (JOB_ID: {new_job.id}") return new_job else: new_datasource = DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Published {0} (ID: {1})".format(filename, new_datasource.id)) + logger.info(f"Published {filename} (ID: {new_datasource.id})") return new_datasource @api(version="3.13") @@ -327,23 +326,23 @@ def update_hyper_data( ) -> JobItem: if isinstance(datasource_or_connection_item, DatasourceItem): datasource_id = datasource_or_connection_item.id - url = "{0}/{1}/data".format(self.baseurl, datasource_id) + url = f"{self.baseurl}/{datasource_id}/data" elif isinstance(datasource_or_connection_item, ConnectionItem): datasource_id = datasource_or_connection_item.datasource_id connection_id = datasource_or_connection_item.id - url = "{0}/{1}/connections/{2}/data".format(self.baseurl, datasource_id, connection_id) + url = f"{self.baseurl}/{datasource_id}/connections/{connection_id}/data" else: assert isinstance(datasource_or_connection_item, str) - url = "{0}/{1}/data".format(self.baseurl, datasource_or_connection_item) + url = f"{self.baseurl}/{datasource_or_connection_item}/data" if payload is not None: if not os.path.isfile(payload): error = "File path does not lead to an existing file." - raise IOError(error) + raise OSError(error) - logger.info("Uploading {0} to server with chunking method for Update job".format(payload)) + logger.info(f"Uploading {payload} to server with chunking method for Update job") upload_session_id = self.parent_srv.fileuploads.upload(payload) - url = "{0}?uploadSessionId={1}".format(url, upload_session_id) + url = f"{url}?uploadSessionId={upload_session_id}" json_request = json.dumps({"actions": actions}) parameters = {"headers": {"requestid": request_id}} @@ -356,7 +355,7 @@ def populate_permissions(self, item: DatasourceItem) -> None: self._permissions.populate(item) @api(version="2.0") - def update_permissions(self, item: DatasourceItem, permission_item: List["PermissionsRule"]) -> None: + def update_permissions(self, item: DatasourceItem, permission_item: list["PermissionsRule"]) -> None: self._permissions.update(item, permission_item) @api(version="2.0") @@ -390,12 +389,12 @@ def revisions_fetcher(): return self._get_datasource_revisions(datasource_item) datasource_item._set_revisions(revisions_fetcher) - logger.info("Populated revisions for datasource (ID: {0})".format(datasource_item.id)) + logger.info(f"Populated revisions for datasource (ID: {datasource_item.id})") def _get_datasource_revisions( self, datasource_item: DatasourceItem, req_options: Optional["RequestOptions"] = None - ) -> List[RevisionItem]: - url = "{0}/{1}/revisions".format(self.baseurl, datasource_item.id) + ) -> list[RevisionItem]: + url = f"{self.baseurl}/{datasource_item.id}/revisions" server_response = self.get_request(url, req_options) revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, datasource_item) return revisions @@ -413,9 +412,9 @@ def download_revision( error = "Datasource ID undefined." raise ValueError(error) if revision_number is None: - url = "{0}/{1}/content".format(self.baseurl, datasource_id) + url = f"{self.baseurl}/{datasource_id}/content" else: - url = "{0}/{1}/revisions/{2}/content".format(self.baseurl, datasource_id, revision_number) + url = f"{self.baseurl}/{datasource_id}/revisions/{revision_number}/content" if not include_extract: url += "?includeExtract=False" @@ -437,9 +436,7 @@ def download_revision( f.write(chunk) return_path = os.path.abspath(download_path) - logger.info( - "Downloaded datasource revision {0} to {1} (ID: {2})".format(revision_number, return_path, datasource_id) - ) + logger.info(f"Downloaded datasource revision {revision_number} to {return_path} (ID: {datasource_id})") return return_path @api(version="2.3") @@ -449,19 +446,17 @@ def delete_revision(self, datasource_id: str, revision_number: str) -> None: url = "/".join([self.baseurl, datasource_id, "revisions", revision_number]) self.delete_request(url) - logger.info( - "Deleted single datasource revision (ID: {0}) (Revision: {1})".format(datasource_id, revision_number) - ) + logger.info(f"Deleted single datasource revision (ID: {datasource_id}) (Revision: {revision_number})") # a convenience method @api(version="2.8") def schedule_extract_refresh( self, schedule_id: str, item: DatasourceItem - ) -> List["AddResponse"]: # actually should return a task + ) -> list["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, datasource=item) @api(version="1.0") - def add_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + def add_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> set[str]: return super().add_tags(item, tags) @api(version="1.0") diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py index 19112d713..343d8b097 100644 --- a/tableauserverclient/server/endpoint/default_permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py @@ -4,7 +4,8 @@ from .exceptions import MissingRequiredFieldError from tableauserverclient.server import RequestFactory from tableauserverclient.models import DatabaseItem, PermissionsRule, ProjectItem, plural_type, Resource -from typing import TYPE_CHECKING, Callable, List, Optional, Sequence, Union +from typing import TYPE_CHECKING, Callable, Optional, Union +from collections.abc import Sequence if TYPE_CHECKING: from ..server import Server @@ -25,7 +26,7 @@ class _DefaultPermissionsEndpoint(Endpoint): """ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> None: - super(_DefaultPermissionsEndpoint, self).__init__(parent_srv) + super().__init__(parent_srv) # owner_baseurl is the baseurl of the parent, a project or database. # It MUST be a lambda since we don't know the full site URL until we sign in. @@ -33,18 +34,18 @@ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> No self.owner_baseurl = owner_baseurl def __str__(self): - return "".format(self.owner_baseurl()) + return f"" __repr__ = __str__ def update_default_permissions( self, resource: BaseItem, permissions: Sequence[PermissionsRule], content_type: Resource - ) -> List[PermissionsRule]: - url = "{0}/{1}/default-permissions/{2}".format(self.owner_baseurl(), resource.id, plural_type(content_type)) + ) -> list[PermissionsRule]: + url = f"{self.owner_baseurl()}/{resource.id}/default-permissions/{plural_type(content_type)}" update_req = RequestFactory.Permission.add_req(permissions) response = self.put_request(url, update_req) permissions = PermissionsRule.from_response(response.content, self.parent_srv.namespace) - logger.info("Updated default {} permissions for resource {}".format(content_type, resource.id)) + logger.info(f"Updated default {content_type} permissions for resource {resource.id}") logger.info(permissions) return permissions @@ -65,29 +66,27 @@ def delete_default_permission(self, resource: BaseItem, rule: PermissionsRule, c ) ) - logger.debug("Removing {0} permission for capability {1}".format(mode, capability)) + logger.debug(f"Removing {mode} permission for capability {capability}") self.delete_request(url) - logger.info( - "Deleted permission for {0} {1} item {2}".format(rule.grantee.tag_name, rule.grantee.id, resource.id) - ) + logger.info(f"Deleted permission for {rule.grantee.tag_name} {rule.grantee.id} item {resource.id}") def populate_default_permissions(self, item: BaseItem, content_type: Resource) -> None: if not item.id: error = "Server item is missing ID. Item must be retrieved from server first." raise MissingRequiredFieldError(error) - def permission_fetcher() -> List[PermissionsRule]: + def permission_fetcher() -> list[PermissionsRule]: return self._get_default_permissions(item, content_type) item._set_default_permissions(permission_fetcher, content_type) - logger.info("Populated default {0} permissions for item (ID: {1})".format(content_type, item.id)) + logger.info(f"Populated default {content_type} permissions for item (ID: {item.id})") def _get_default_permissions( self, item: BaseItem, content_type: Resource, req_options: Optional["RequestOptions"] = None - ) -> List[PermissionsRule]: - url = "{0}/{1}/default-permissions/{2}".format(self.owner_baseurl(), item.id, plural_type(content_type)) + ) -> list[PermissionsRule]: + url = f"{self.owner_baseurl()}/{item.id}/default-permissions/{plural_type(content_type)}" server_response = self.get_request(url, req_options) permissions = PermissionsRule.from_response(server_response.content, self.parent_srv.namespace) logger.info({"content_type": content_type, "permissions": permissions}) diff --git a/tableauserverclient/server/endpoint/dqw_endpoint.py b/tableauserverclient/server/endpoint/dqw_endpoint.py index 5296523ee..90e31483b 100644 --- a/tableauserverclient/server/endpoint/dqw_endpoint.py +++ b/tableauserverclient/server/endpoint/dqw_endpoint.py @@ -10,35 +10,35 @@ class _DataQualityWarningEndpoint(Endpoint): def __init__(self, parent_srv, resource_type): - super(_DataQualityWarningEndpoint, self).__init__(parent_srv) + super().__init__(parent_srv) self.resource_type = resource_type @property def baseurl(self): - return "{0}/sites/{1}/dataQualityWarnings/{2}".format( + return "{}/sites/{}/dataQualityWarnings/{}".format( self.parent_srv.baseurl, self.parent_srv.site_id, self.resource_type ) def add(self, resource, warning): - url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=resource.id) + url = f"{self.baseurl}/{resource.id}" add_req = RequestFactory.DQW.add_req(warning) response = self.post_request(url, add_req) warnings = DQWItem.from_response(response.content, self.parent_srv.namespace) - logger.info("Added dqw for resource {0}".format(resource.id)) + logger.info(f"Added dqw for resource {resource.id}") return warnings def update(self, resource, warning): - url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=resource.id) + url = f"{self.baseurl}/{resource.id}" add_req = RequestFactory.DQW.update_req(warning) response = self.put_request(url, add_req) warnings = DQWItem.from_response(response.content, self.parent_srv.namespace) - logger.info("Added dqw for resource {0}".format(resource.id)) + logger.info(f"Added dqw for resource {resource.id}") return warnings def clear(self, resource): - url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=resource.id) + url = f"{self.baseurl}/{resource.id}" return self.delete_request(url) def populate(self, item): @@ -50,10 +50,10 @@ def dqw_fetcher(): return self._get_data_quality_warnings(item) item._set_data_quality_warnings(dqw_fetcher) - logger.info("Populated permissions for item (ID: {0})".format(item.id)) + logger.info(f"Populated permissions for item (ID: {item.id})") def _get_data_quality_warnings(self, item, req_options=None): - url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=item.id) + url = f"{self.baseurl}/{item.id}" server_response = self.get_request(url, req_options) dqws = DQWItem.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index be0602df5..bef96fdee 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -8,12 +8,9 @@ from typing import ( Any, Callable, - Dict, Generic, - List, Optional, TYPE_CHECKING, - Tuple, TypeVar, Union, ) @@ -56,7 +53,7 @@ def __init__(self, parent_srv: "Server"): async_response = None @staticmethod - def set_parameters(http_options, auth_token, content, content_type, parameters) -> Dict[str, Any]: + def set_parameters(http_options, auth_token, content, content_type, parameters) -> dict[str, Any]: parameters = parameters or {} parameters.update(http_options) if "headers" not in parameters: @@ -82,7 +79,7 @@ def set_user_agent(parameters): else: # only set the TSC user agent if not already populated _client_version: Optional[str] = get_versions()["version"] - parameters["headers"][USER_AGENT_HEADER] = "Tableau Server Client/{}".format(_client_version) + parameters["headers"][USER_AGENT_HEADER] = f"Tableau Server Client/{_client_version}" # result: parameters["headers"]["User-Agent"] is set # return explicitly for testing only @@ -90,12 +87,12 @@ def set_user_agent(parameters): def _blocking_request(self, method, url, parameters={}) -> Optional[Union["Response", Exception]]: response = None - logger.debug("[{}] Begin blocking request to {}".format(datetime.timestamp(), url)) + logger.debug(f"[{datetime.timestamp()}] Begin blocking request to {url}") try: response = method(url, **parameters) - logger.debug("[{}] Call finished".format(datetime.timestamp())) + logger.debug(f"[{datetime.timestamp()}] Call finished") except Exception as e: - logger.debug("Error making request to server: {}".format(e)) + logger.debug(f"Error making request to server: {e}") raise e return response @@ -111,13 +108,13 @@ def _make_request( content: Optional[bytes] = None, auth_token: Optional[str] = None, content_type: Optional[str] = None, - parameters: Optional[Dict[str, Any]] = None, + parameters: Optional[dict[str, Any]] = None, ) -> "Response": parameters = Endpoint.set_parameters( self.parent_srv.http_options, auth_token, content, content_type, parameters ) - logger.debug("request method {}, url: {}".format(method.__name__, url)) + logger.debug(f"request method {method.__name__}, url: {url}") if content: redacted = helpers.strings.redact_xml(content[:200]) # this needs to be under a trace or something, it's a LOT @@ -129,14 +126,14 @@ def _make_request( server_response: Optional[Union["Response", Exception]] = self.send_request_while_show_progress_threaded( method, url, parameters, request_timeout ) - logger.debug("[{}] Async request returned: received {}".format(datetime.timestamp(), server_response)) + logger.debug(f"[{datetime.timestamp()}] Async request returned: received {server_response}") # is this blocking retry really necessary? I guess if it was just the threading messing it up? if server_response is None: logger.debug(server_response) - logger.debug("[{}] Async request failed: retrying".format(datetime.timestamp())) + logger.debug(f"[{datetime.timestamp()}] Async request failed: retrying") server_response = self._blocking_request(method, url, parameters) if server_response is None: - logger.debug("[{}] Request failed".format(datetime.timestamp())) + logger.debug(f"[{datetime.timestamp()}] Request failed") raise RuntimeError if isinstance(server_response, Exception): raise server_response @@ -154,9 +151,9 @@ def _make_request( return server_response def _check_status(self, server_response: "Response", url: Optional[str] = None): - logger.debug("Response status: {}".format(server_response)) + logger.debug(f"Response status: {server_response}") if not hasattr(server_response, "status_code"): - raise EnvironmentError("Response is not a http response?") + raise OSError("Response is not a http response?") if server_response.status_code >= 500: raise InternalServerError(server_response, url) elif server_response.status_code not in Success_codes: @@ -183,9 +180,9 @@ def log_response_safely(self, server_response: "Response") -> str: # content-type is an octet-stream accomplishes the same goal without eagerly loading content. # This check is to determine if the response is a text response (xml or otherwise) # so that we do not attempt to log bytes and other binary data. - loggable_response = "Content type `{}`".format(content_type) + loggable_response = f"Content type `{content_type}`" if content_type == "application/octet-stream": - loggable_response = "A stream of type {} [Truncated File Contents]".format(content_type) + loggable_response = f"A stream of type {content_type} [Truncated File Contents]" elif server_response.encoding and len(server_response.content) > 0: loggable_response = helpers.strings.redact_xml(server_response.content.decode(server_response.encoding)) return loggable_response @@ -313,7 +310,7 @@ def wrapper(self: E, *args: P.args, **kwargs: P.kwargs) -> R: for p in params_to_check: min_ver = Version(str(params[p])) if server_ver < min_ver: - error = "{!r} not available in {}, it will be ignored. Added in {}".format(p, server_ver, min_ver) + error = f"{p!r} not available in {server_ver}, it will be ignored. Added in {min_ver}" warnings.warn(error) return func(self, *args, **kwargs) @@ -353,5 +350,5 @@ def paginate(self, **kwargs) -> QuerySet[T]: return queryset @abc.abstractmethod - def get(self, request_options: Optional[RequestOptions] = None) -> Tuple[List[T], PaginationItem]: + def get(self, request_options: Optional[RequestOptions] = None) -> tuple[list[T], PaginationItem]: raise NotImplementedError(f".get has not been implemented for {self.__class__.__qualname__}") diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 9dfd38da6..17d789d01 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -12,10 +12,10 @@ def __init__(self, code, summary, detail, url=None): self.summary = summary self.detail = detail self.url = url - super(ServerResponseError, self).__init__(str(self)) + super().__init__(str(self)) def __str__(self): - return "\n\n\t{0}: {1}\n\t\t{2}".format(self.code, self.summary, self.detail) + return f"\n\n\t{self.code}: {self.summary}\n\t\t{self.detail}" @classmethod def from_response(cls, resp, ns, url=None): @@ -40,7 +40,7 @@ def __init__(self, server_response, request_url: Optional[str] = None): self.url = request_url or "server" def __str__(self): - return "\n\nInternal error {0} at {1}\n{2}".format(self.code, self.url, self.content) + return f"\n\nInternal error {self.code} at {self.url}\n{self.content}" class MissingRequiredFieldError(TableauError): diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index 5f298f37e..8330e6d2c 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -20,13 +20,13 @@ class Favorites(Endpoint): @property def baseurl(self) -> str: - return "{0}/sites/{1}/favorites".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/favorites" # Gets all favorites @api(version="2.5") def get(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: - logger.info("Querying all favorites for user {0}".format(user_item.name)) - url = "{0}/{1}".format(self.baseurl, user_item.id) + logger.info(f"Querying all favorites for user {user_item.name}") + url = f"{self.baseurl}/{user_item.id}" server_response = self.get_request(url, req_options) user_item._favorites = FavoriteItem.from_response(server_response.content, self.parent_srv.namespace) @@ -34,53 +34,53 @@ def get(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) @api(version="3.15") def add_favorite(self, user_item: UserItem, content_type: str, item: TableauItem) -> "Response": - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_request(item.id, content_type, item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited {0} for user (ID: {1})".format(item.name, user_item.id)) + logger.info(f"Favorited {item.name} for user (ID: {user_item.id})") return server_response @api(version="2.0") def add_favorite_workbook(self, user_item: UserItem, workbook_item: WorkbookItem) -> None: - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_workbook_req(workbook_item.id, workbook_item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited {0} for user (ID: {1})".format(workbook_item.name, user_item.id)) + logger.info(f"Favorited {workbook_item.name} for user (ID: {user_item.id})") @api(version="2.0") def add_favorite_view(self, user_item: UserItem, view_item: ViewItem) -> None: - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_view_req(view_item.id, view_item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited {0} for user (ID: {1})".format(view_item.name, user_item.id)) + logger.info(f"Favorited {view_item.name} for user (ID: {user_item.id})") @api(version="2.3") def add_favorite_datasource(self, user_item: UserItem, datasource_item: DatasourceItem) -> None: - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_datasource_req(datasource_item.id, datasource_item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited {0} for user (ID: {1})".format(datasource_item.name, user_item.id)) + logger.info(f"Favorited {datasource_item.name} for user (ID: {user_item.id})") @api(version="3.1") def add_favorite_project(self, user_item: UserItem, project_item: ProjectItem) -> None: - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_project_req(project_item.id, project_item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited {0} for user (ID: {1})".format(project_item.name, user_item.id)) + logger.info(f"Favorited {project_item.name} for user (ID: {user_item.id})") @api(version="3.3") def add_favorite_flow(self, user_item: UserItem, flow_item: FlowItem) -> None: - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_flow_req(flow_item.id, flow_item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited {0} for user (ID: {1})".format(flow_item.name, user_item.id)) + logger.info(f"Favorited {flow_item.name} for user (ID: {user_item.id})") @api(version="3.3") def add_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> None: - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_request(metric_item.id, Resource.Metric, metric_item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited metric {0} for user (ID: {1})".format(metric_item.name, user_item.id)) + logger.info(f"Favorited metric {metric_item.name} for user (ID: {user_item.id})") # ------- delete from favorites # Response: @@ -94,42 +94,42 @@ def add_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> N @api(version="3.15") def delete_favorite(self, user_item: UserItem, content_type: Resource, item: TableauItem) -> None: - url = "{0}/{1}/{2}/{3}".format(self.baseurl, user_item.id, content_type, item.id) - logger.info("Removing favorite {0}({1}) for user (ID: {2})".format(content_type, item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/{content_type}/{item.id}" + logger.info(f"Removing favorite {content_type}({item.id}) for user (ID: {user_item.id})") self.delete_request(url) @api(version="2.0") def delete_favorite_workbook(self, user_item: UserItem, workbook_item: WorkbookItem) -> None: - url = "{0}/{1}/workbooks/{2}".format(self.baseurl, user_item.id, workbook_item.id) - logger.info("Removing favorite workbook {0} for user (ID: {1})".format(workbook_item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/workbooks/{workbook_item.id}" + logger.info(f"Removing favorite workbook {workbook_item.id} for user (ID: {user_item.id})") self.delete_request(url) @api(version="2.0") def delete_favorite_view(self, user_item: UserItem, view_item: ViewItem) -> None: - url = "{0}/{1}/views/{2}".format(self.baseurl, user_item.id, view_item.id) - logger.info("Removing favorite view {0} for user (ID: {1})".format(view_item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/views/{view_item.id}" + logger.info(f"Removing favorite view {view_item.id} for user (ID: {user_item.id})") self.delete_request(url) @api(version="2.3") def delete_favorite_datasource(self, user_item: UserItem, datasource_item: DatasourceItem) -> None: - url = "{0}/{1}/datasources/{2}".format(self.baseurl, user_item.id, datasource_item.id) - logger.info("Removing favorite {0} for user (ID: {1})".format(datasource_item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/datasources/{datasource_item.id}" + logger.info(f"Removing favorite {datasource_item.id} for user (ID: {user_item.id})") self.delete_request(url) @api(version="3.1") def delete_favorite_project(self, user_item: UserItem, project_item: ProjectItem) -> None: - url = "{0}/{1}/projects/{2}".format(self.baseurl, user_item.id, project_item.id) - logger.info("Removing favorite project {0} for user (ID: {1})".format(project_item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/projects/{project_item.id}" + logger.info(f"Removing favorite project {project_item.id} for user (ID: {user_item.id})") self.delete_request(url) @api(version="3.3") def delete_favorite_flow(self, user_item: UserItem, flow_item: FlowItem) -> None: - url = "{0}/{1}/flows/{2}".format(self.baseurl, user_item.id, flow_item.id) - logger.info("Removing favorite flow {0} for user (ID: {1})".format(flow_item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/flows/{flow_item.id}" + logger.info(f"Removing favorite flow {flow_item.id} for user (ID: {user_item.id})") self.delete_request(url) @api(version="3.15") def delete_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> None: - url = "{0}/{1}/metrics/{2}".format(self.baseurl, user_item.id, metric_item.id) - logger.info("Removing favorite metric {0} for user (ID: {1})".format(metric_item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/metrics/{metric_item.id}" + logger.info(f"Removing favorite metric {metric_item.id} for user (ID: {user_item.id})") self.delete_request(url) diff --git a/tableauserverclient/server/endpoint/fileuploads_endpoint.py b/tableauserverclient/server/endpoint/fileuploads_endpoint.py index 0d30797c1..1ae10e72d 100644 --- a/tableauserverclient/server/endpoint/fileuploads_endpoint.py +++ b/tableauserverclient/server/endpoint/fileuploads_endpoint.py @@ -9,11 +9,11 @@ class Fileuploads(Endpoint): def __init__(self, parent_srv): - super(Fileuploads, self).__init__(parent_srv) + super().__init__(parent_srv) @property def baseurl(self): - return "{0}/sites/{1}/fileUploads".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/fileUploads" @api(version="2.0") def initiate(self): @@ -21,14 +21,14 @@ def initiate(self): server_response = self.post_request(url, "") fileupload_item = FileuploadItem.from_response(server_response.content, self.parent_srv.namespace) upload_id = fileupload_item.upload_session_id - logger.info("Initiated file upload session (ID: {0})".format(upload_id)) + logger.info(f"Initiated file upload session (ID: {upload_id})") return upload_id @api(version="2.0") def append(self, upload_id, data, content_type): - url = "{0}/{1}".format(self.baseurl, upload_id) + url = f"{self.baseurl}/{upload_id}" server_response = self.put_request(url, data, content_type) - logger.info("Uploading a chunk to session (ID: {0})".format(upload_id)) + logger.info(f"Uploading a chunk to session (ID: {upload_id})") return FileuploadItem.from_response(server_response.content, self.parent_srv.namespace) def _read_chunks(self, file): @@ -52,12 +52,10 @@ def _read_chunks(self, file): def upload(self, file): upload_id = self.initiate() for chunk in self._read_chunks(file): - logger.debug("{} processing chunk...".format(datetime.timestamp())) + logger.debug(f"{datetime.timestamp()} processing chunk...") request, content_type = RequestFactory.Fileupload.chunk_req(chunk) - logger.debug("{} created chunk request".format(datetime.timestamp())) + logger.debug(f"{datetime.timestamp()} created chunk request") fileupload_item = self.append(upload_id, request, content_type) - logger.info( - "\t{0} Published {1}MB".format(datetime.timestamp(), (fileupload_item.file_size / BYTES_PER_MB)) - ) - logger.info("File upload finished (ID: {0})".format(upload_id)) + logger.info(f"\t{datetime.timestamp()} Published {(fileupload_item.file_size / BYTES_PER_MB)}MB") + logger.info(f"File upload finished (ID: {upload_id})") return upload_id diff --git a/tableauserverclient/server/endpoint/flow_runs_endpoint.py b/tableauserverclient/server/endpoint/flow_runs_endpoint.py index c339a0645..3d09ad569 100644 --- a/tableauserverclient/server/endpoint/flow_runs_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_runs_endpoint.py @@ -1,5 +1,5 @@ import logging -from typing import List, Optional, Tuple, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api from tableauserverclient.server.endpoint.exceptions import FlowRunFailedException, FlowRunCancelledException @@ -16,16 +16,16 @@ class FlowRuns(QuerysetEndpoint[FlowRunItem]): def __init__(self, parent_srv: "Server") -> None: - super(FlowRuns, self).__init__(parent_srv) + super().__init__(parent_srv) return None @property def baseurl(self) -> str: - return "{0}/sites/{1}/flows/runs".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/flows/runs" # Get all flows @api(version="3.10") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[FlowRunItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[FlowRunItem], PaginationItem]: logger.info("Querying all flow runs on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -39,8 +39,8 @@ def get_by_id(self, flow_run_id: str) -> FlowRunItem: if not flow_run_id: error = "Flow ID undefined." raise ValueError(error) - logger.info("Querying single flow (ID: {0})".format(flow_run_id)) - url = "{0}/{1}".format(self.baseurl, flow_run_id) + logger.info(f"Querying single flow (ID: {flow_run_id})") + url = f"{self.baseurl}/{flow_run_id}" server_response = self.get_request(url) return FlowRunItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -51,9 +51,9 @@ def cancel(self, flow_run_id: str) -> None: error = "Flow ID undefined." raise ValueError(error) id_ = getattr(flow_run_id, "id", flow_run_id) - url = "{0}/{1}".format(self.baseurl, id_) + url = f"{self.baseurl}/{id_}" self.put_request(url) - logger.info("Deleted single flow (ID: {0})".format(id_)) + logger.info(f"Deleted single flow (ID: {id_})") @api(version="3.10") def wait_for_job(self, flow_run_id: str, *, timeout: Optional[int] = None) -> FlowRunItem: @@ -69,7 +69,7 @@ def wait_for_job(self, flow_run_id: str, *, timeout: Optional[int] = None) -> Fl flow_run = self.get_by_id(flow_run_id) logger.debug(f"\tFlowRun {flow_run_id} progress={flow_run.progress}") - logger.info("FlowRun {} Completed: Status: {}".format(flow_run_id, flow_run.status)) + logger.info(f"FlowRun {flow_run_id} Completed: Status: {flow_run.status}") if flow_run.status == "Success": return flow_run diff --git a/tableauserverclient/server/endpoint/flow_task_endpoint.py b/tableauserverclient/server/endpoint/flow_task_endpoint.py index eea3f9710..9e21661e6 100644 --- a/tableauserverclient/server/endpoint/flow_task_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_task_endpoint.py @@ -1,5 +1,5 @@ import logging -from typing import List, Optional, Tuple, TYPE_CHECKING +from typing import TYPE_CHECKING from tableauserverclient.server.endpoint.endpoint import Endpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError @@ -15,7 +15,7 @@ class FlowTasks(Endpoint): @property def baseurl(self) -> str: - return "{0}/sites/{1}/tasks/flows".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks/flows" @api(version="3.22") def create(self, flow_item: TaskItem) -> TaskItem: diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 53d072f50..7eb5dc3ba 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -5,7 +5,8 @@ import os from contextlib import closing from pathlib import Path -from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union +from typing import Optional, TYPE_CHECKING, Union +from collections.abc import Iterable from tableauserverclient.helpers.headers import fix_filename @@ -53,18 +54,18 @@ class Flows(QuerysetEndpoint[FlowItem], TaggingMixin[FlowItem]): def __init__(self, parent_srv): - super(Flows, self).__init__(parent_srv) + super().__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "flow") @property def baseurl(self) -> str: - return "{0}/sites/{1}/flows".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/flows" # Get all flows @api(version="3.3") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[FlowItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[FlowItem], PaginationItem]: logger.info("Querying all flows on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -78,8 +79,8 @@ def get_by_id(self, flow_id: str) -> FlowItem: if not flow_id: error = "Flow ID undefined." raise ValueError(error) - logger.info("Querying single flow (ID: {0})".format(flow_id)) - url = "{0}/{1}".format(self.baseurl, flow_id) + logger.info(f"Querying single flow (ID: {flow_id})") + url = f"{self.baseurl}/{flow_id}" server_response = self.get_request(url) return FlowItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -94,10 +95,10 @@ def connections_fetcher(): return self._get_flow_connections(flow_item) flow_item._set_connections(connections_fetcher) - logger.info("Populated connections for flow (ID: {0})".format(flow_item.id)) + logger.info(f"Populated connections for flow (ID: {flow_item.id})") - def _get_flow_connections(self, flow_item, req_options: Optional["RequestOptions"] = None) -> List[ConnectionItem]: - url = "{0}/{1}/connections".format(self.baseurl, flow_item.id) + def _get_flow_connections(self, flow_item, req_options: Optional["RequestOptions"] = None) -> list[ConnectionItem]: + url = f"{self.baseurl}/{flow_item.id}/connections" server_response = self.get_request(url, req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) return connections @@ -108,9 +109,9 @@ def delete(self, flow_id: str) -> None: if not flow_id: error = "Flow ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, flow_id) + url = f"{self.baseurl}/{flow_id}" self.delete_request(url) - logger.info("Deleted single flow (ID: {0})".format(flow_id)) + logger.info(f"Deleted single flow (ID: {flow_id})") # Download 1 flow by id @api(version="3.3") @@ -118,7 +119,7 @@ def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> Path if not flow_id: error = "Flow ID undefined." raise ValueError(error) - url = "{0}/{1}/content".format(self.baseurl, flow_id) + url = f"{self.baseurl}/{flow_id}/content" with closing(self.get_request(url, parameters={"stream": True})) as server_response: m = Message() @@ -137,7 +138,7 @@ def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> Path f.write(chunk) return_path = os.path.abspath(download_path) - logger.info("Downloaded flow to {0} (ID: {1})".format(return_path, flow_id)) + logger.info(f"Downloaded flow to {return_path} (ID: {flow_id})") return return_path # Update flow @@ -150,28 +151,28 @@ def update(self, flow_item: FlowItem) -> FlowItem: self._resource_tagger.update_tags(self.baseurl, flow_item) # Update the flow itself - url = "{0}/{1}".format(self.baseurl, flow_item.id) + url = f"{self.baseurl}/{flow_item.id}" update_req = RequestFactory.Flow.update_req(flow_item) server_response = self.put_request(url, update_req) - logger.info("Updated flow item (ID: {0})".format(flow_item.id)) + logger.info(f"Updated flow item (ID: {flow_item.id})") updated_flow = copy.copy(flow_item) return updated_flow._parse_common_elements(server_response.content, self.parent_srv.namespace) # Update flow connections @api(version="3.3") def update_connection(self, flow_item: FlowItem, connection_item: ConnectionItem) -> ConnectionItem: - url = "{0}/{1}/connections/{2}".format(self.baseurl, flow_item.id, connection_item.id) + url = f"{self.baseurl}/{flow_item.id}/connections/{connection_item.id}" update_req = RequestFactory.Connection.update_req(connection_item) server_response = self.put_request(url, update_req) connection = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Updated flow item (ID: {0} & connection item {1}".format(flow_item.id, connection_item.id)) + logger.info(f"Updated flow item (ID: {flow_item.id} & connection item {connection_item.id}") return connection @api(version="3.3") def refresh(self, flow_item: FlowItem) -> JobItem: - url = "{0}/{1}/run".format(self.baseurl, flow_item.id) + url = f"{self.baseurl}/{flow_item.id}/run" empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -180,7 +181,7 @@ def refresh(self, flow_item: FlowItem) -> JobItem: # Publish flow @api(version="3.3") def publish( - self, flow_item: FlowItem, file: PathOrFileR, mode: str, connections: Optional[List[ConnectionItem]] = None + self, flow_item: FlowItem, file: PathOrFileR, mode: str, connections: Optional[list[ConnectionItem]] = None ) -> FlowItem: if not mode or not hasattr(self.parent_srv.PublishMode, mode): error = "Invalid mode defined." @@ -189,7 +190,7 @@ def publish( if isinstance(file, (str, os.PathLike)): if not os.path.isfile(file): error = "File path does not lead to an existing file." - raise IOError(error) + raise OSError(error) filename = os.path.basename(file) file_extension = os.path.splitext(filename)[1][1:] @@ -213,30 +214,30 @@ def publish( elif file_type == "xml": file_extension = "tfl" else: - error = "Unsupported file type {}!".format(file_type) + error = f"Unsupported file type {file_type}!" raise ValueError(error) # Generate filename for file object. # This is needed when publishing the flow in a single request - filename = "{}.{}".format(flow_item.name, file_extension) + filename = f"{flow_item.name}.{file_extension}" file_size = get_file_object_size(file) else: raise TypeError("file should be a filepath or file object.") # Construct the url with the defined mode - url = "{0}?flowType={1}".format(self.baseurl, file_extension) + url = f"{self.baseurl}?flowType={file_extension}" if mode == self.parent_srv.PublishMode.Overwrite or mode == self.parent_srv.PublishMode.Append: - url += "&{0}=true".format(mode.lower()) + url += f"&{mode.lower()}=true" # Determine if chunking is required (64MB is the limit for single upload method) if file_size >= FILESIZE_LIMIT: - logger.info("Publishing {0} to server with chunking method (flow over 64MB)".format(filename)) + logger.info(f"Publishing {filename} to server with chunking method (flow over 64MB)") upload_session_id = self.parent_srv.fileuploads.upload(file) - url = "{0}&uploadSessionId={1}".format(url, upload_session_id) + url = f"{url}&uploadSessionId={upload_session_id}" xml_request, content_type = RequestFactory.Flow.publish_req_chunked(flow_item, connections) else: - logger.info("Publishing {0} to server".format(filename)) + logger.info(f"Publishing {filename} to server") if isinstance(file, (str, Path)): with open(file, "rb") as f: @@ -259,7 +260,7 @@ def publish( raise err else: new_flow = FlowItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Published {0} (ID: {1})".format(filename, new_flow.id)) + logger.info(f"Published {filename} (ID: {new_flow.id})") return new_flow @api(version="3.3") @@ -294,7 +295,7 @@ def delete_dqw(self, item: FlowItem) -> None: @api(version="3.3") def schedule_flow_run( self, schedule_id: str, item: FlowItem - ) -> List["AddResponse"]: # actually should return a task + ) -> list["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, flow=item) def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[FlowItem]: diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index 8acf31692..c512b011b 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -8,7 +8,8 @@ from tableauserverclient.helpers.logging import logger -from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union +from typing import Optional, TYPE_CHECKING, Union +from collections.abc import Iterable from tableauserverclient.server.query import QuerySet @@ -19,10 +20,10 @@ class Groups(QuerysetEndpoint[GroupItem]): @property def baseurl(self) -> str: - return "{0}/sites/{1}/groups".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/groups" @api(version="2.0") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[GroupItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[GroupItem], PaginationItem]: """Gets all groups""" logger.info("Querying all groups on site") url = self.baseurl @@ -50,12 +51,12 @@ def user_pager(): def _get_users_for_group( self, group_item: GroupItem, req_options: Optional["RequestOptions"] = None - ) -> Tuple[List[UserItem], PaginationItem]: - url = "{0}/{1}/users".format(self.baseurl, group_item.id) + ) -> tuple[list[UserItem], PaginationItem]: + url = f"{self.baseurl}/{group_item.id}/users" server_response = self.get_request(url, req_options) user_item = UserItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) - logger.info("Populated users for group (ID: {0})".format(group_item.id)) + logger.info(f"Populated users for group (ID: {group_item.id})") return user_item, pagination_item @api(version="2.0") @@ -64,13 +65,13 @@ def delete(self, group_id: str) -> None: if not group_id: error = "Group ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, group_id) + url = f"{self.baseurl}/{group_id}" self.delete_request(url) - logger.info("Deleted single group (ID: {0})".format(group_id)) + logger.info(f"Deleted single group (ID: {group_id})") @api(version="2.0") def update(self, group_item: GroupItem, as_job: bool = False) -> Union[GroupItem, JobItem]: - url = "{0}/{1}".format(self.baseurl, group_item.id) + url = f"{self.baseurl}/{group_item.id}" if not group_item.id: error = "Group item missing ID." @@ -83,7 +84,7 @@ def update(self, group_item: GroupItem, as_job: bool = False) -> Union[GroupItem 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)) + logger.info(f"Updated group item (ID: {group_item.id})") if as_job: return JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] else: @@ -118,9 +119,9 @@ def remove_user(self, group_item: GroupItem, user_id: str) -> None: if not user_id: error = "User ID undefined." raise ValueError(error) - url = "{0}/{1}/users/{2}".format(self.baseurl, group_item.id, user_id) + url = f"{self.baseurl}/{group_item.id}/users/{user_id}" self.delete_request(url) - logger.info("Removed user (id: {0}) from group (ID: {1})".format(user_id, group_item.id)) + logger.info(f"Removed user (id: {user_id}) from group (ID: {group_item.id})") @api(version="3.21") def remove_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> None: @@ -132,7 +133,7 @@ def remove_users(self, group_item: GroupItem, users: Iterable[Union[str, UserIte url = f"{self.baseurl}/{group_id}/users/remove" add_req = RequestFactory.Group.remove_users_req(users) _ = self.put_request(url, add_req) - logger.info("Removed users to group (ID: {0})".format(group_item.id)) + logger.info(f"Removed users to group (ID: {group_item.id})") return None @api(version="2.0") @@ -144,15 +145,15 @@ def add_user(self, group_item: GroupItem, user_id: str) -> UserItem: if not user_id: error = "User ID undefined." raise ValueError(error) - url = "{0}/{1}/users".format(self.baseurl, group_item.id) + url = f"{self.baseurl}/{group_item.id}/users" add_req = RequestFactory.Group.add_user_req(user_id) server_response = self.post_request(url, add_req) user = UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() - logger.info("Added user (id: {0}) to group (ID: {1})".format(user_id, group_item.id)) + logger.info(f"Added user (id: {user_id}) to group (ID: {group_item.id})") return user @api(version="3.21") - def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> List[UserItem]: + def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> list[UserItem]: """Adds multiple users to 1 group""" group_id = group_item.id if hasattr(group_item, "id") else group_item if not isinstance(group_id, str): @@ -162,7 +163,7 @@ def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]] add_req = RequestFactory.Group.add_users_req(users) server_response = self.post_request(url, add_req) users = UserItem.from_response(server_response.content, self.parent_srv.namespace) - logger.info("Added users to group (ID: {0})".format(group_item.id)) + logger.info(f"Added users to group (ID: {group_item.id})") return users def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[GroupItem]: diff --git a/tableauserverclient/server/endpoint/groupsets_endpoint.py b/tableauserverclient/server/endpoint/groupsets_endpoint.py index 06e7cc627..c7f5ed0e5 100644 --- a/tableauserverclient/server/endpoint/groupsets_endpoint.py +++ b/tableauserverclient/server/endpoint/groupsets_endpoint.py @@ -1,4 +1,4 @@ -from typing import List, Literal, Optional, Tuple, TYPE_CHECKING, Union +from typing import Literal, Optional, TYPE_CHECKING, Union from tableauserverclient.helpers.logging import logger from tableauserverclient.models.group_item import GroupItem @@ -27,7 +27,7 @@ def get( self, request_options: Optional[RequestOptions] = None, result_level: Optional[Literal["members", "local"]] = None, - ) -> Tuple[List[GroupSetItem], PaginationItem]: + ) -> tuple[list[GroupSetItem], PaginationItem]: logger.info("Querying all group sets on site") url = self.baseurl if result_level: diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index ae8cf2633..723d3dd38 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -11,24 +11,24 @@ from tableauserverclient.helpers.logging import logger -from typing import List, Optional, Tuple, Union +from typing import Optional, Union class Jobs(QuerysetEndpoint[BackgroundJobItem]): @property def baseurl(self): - return "{0}/sites/{1}/jobs".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/jobs" @overload # type: ignore[override] def get(self: Self, job_id: str, req_options: Optional[RequestOptionsBase] = None) -> JobItem: # type: ignore[override] ... @overload # type: ignore[override] - def get(self: Self, job_id: RequestOptionsBase, req_options: None) -> Tuple[List[BackgroundJobItem], PaginationItem]: # type: ignore[override] + def get(self: Self, job_id: RequestOptionsBase, req_options: None) -> tuple[list[BackgroundJobItem], PaginationItem]: # type: ignore[override] ... @overload # type: ignore[override] - def get(self: Self, job_id: None, req_options: Optional[RequestOptionsBase]) -> Tuple[List[BackgroundJobItem], PaginationItem]: # type: ignore[override] + def get(self: Self, job_id: None, req_options: Optional[RequestOptionsBase]) -> tuple[list[BackgroundJobItem], PaginationItem]: # type: ignore[override] ... @api(version="2.6") @@ -53,13 +53,13 @@ def cancel(self, job_id: Union[str, JobItem]): if isinstance(job_id, JobItem): job_id = job_id.id assert isinstance(job_id, str) - url = "{0}/{1}".format(self.baseurl, job_id) + url = f"{self.baseurl}/{job_id}" return self.put_request(url) @api(version="2.6") def get_by_id(self, job_id: str) -> JobItem: logger.info("Query for information about job " + job_id) - url = "{0}/{1}".format(self.baseurl, job_id) + url = f"{self.baseurl}/{job_id}" server_response = self.get_request(url) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job @@ -77,7 +77,7 @@ def wait_for_job(self, job_id: Union[str, JobItem], *, timeout: Optional[float] job = self.get_by_id(job_id) logger.debug(f"\tJob {job_id} progress={job.progress}") - logger.info("Job {} Completed: Finish Code: {} - Notes:{}".format(job_id, job.finish_code, job.notes)) + logger.info(f"Job {job_id} Completed: Finish Code: {job.finish_code} - Notes:{job.notes}") if job.finish_code == JobItem.FinishCode.Success: return job diff --git a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py index 374130509..ede4d38e3 100644 --- a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Tuple, Union +from typing import Optional, Union from tableauserverclient.helpers.logging import logger from tableauserverclient.models.linked_tasks_item import LinkedTaskItem, LinkedTaskJobItem @@ -18,7 +18,7 @@ def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks/linked" @api(version="3.15") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[LinkedTaskItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[LinkedTaskItem], PaginationItem]: logger.info("Querying all linked tasks on site") url = self.baseurl server_response = self.get_request(url, req_options) diff --git a/tableauserverclient/server/endpoint/metadata_endpoint.py b/tableauserverclient/server/endpoint/metadata_endpoint.py index 38c3eebb6..e5dbcbcf8 100644 --- a/tableauserverclient/server/endpoint/metadata_endpoint.py +++ b/tableauserverclient/server/endpoint/metadata_endpoint.py @@ -50,11 +50,11 @@ def get_page_info(result): class Metadata(Endpoint): @property def baseurl(self): - return "{0}/api/metadata/graphql".format(self.parent_srv.server_address) + return f"{self.parent_srv.server_address}/api/metadata/graphql" @property def control_baseurl(self): - return "{0}/api/metadata/v1/control".format(self.parent_srv.server_address) + return f"{self.parent_srv.server_address}/api/metadata/v1/control" @api("3.5") def query(self, query, variables=None, abort_on_error=False, parameters=None): diff --git a/tableauserverclient/server/endpoint/metrics_endpoint.py b/tableauserverclient/server/endpoint/metrics_endpoint.py index ab1ec5852..3fea1f5b6 100644 --- a/tableauserverclient/server/endpoint/metrics_endpoint.py +++ b/tableauserverclient/server/endpoint/metrics_endpoint.py @@ -8,7 +8,7 @@ import logging -from typing import List, Optional, TYPE_CHECKING, Tuple +from typing import Optional, TYPE_CHECKING if TYPE_CHECKING: from ..request_options import RequestOptions @@ -20,18 +20,18 @@ class Metrics(QuerysetEndpoint[MetricItem]): def __init__(self, parent_srv: "Server") -> None: - super(Metrics, self).__init__(parent_srv) + super().__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "metric") @property def baseurl(self) -> str: - return "{0}/sites/{1}/metrics".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/metrics" # Get all metrics @api(version="3.9") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[MetricItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[MetricItem], PaginationItem]: logger.info("Querying all metrics on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -45,8 +45,8 @@ def get_by_id(self, metric_id: str) -> MetricItem: if not metric_id: error = "Metric ID undefined." raise ValueError(error) - logger.info("Querying single metric (ID: {0})".format(metric_id)) - url = "{0}/{1}".format(self.baseurl, metric_id) + logger.info(f"Querying single metric (ID: {metric_id})") + url = f"{self.baseurl}/{metric_id}" server_response = self.get_request(url) return MetricItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -56,9 +56,9 @@ def delete(self, metric_id: str) -> None: if not metric_id: error = "Metric ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, metric_id) + url = f"{self.baseurl}/{metric_id}" self.delete_request(url) - logger.info("Deleted single metric (ID: {0})".format(metric_id)) + logger.info(f"Deleted single metric (ID: {metric_id})") # Update metric @api(version="3.9") @@ -70,8 +70,8 @@ def update(self, metric_item: MetricItem) -> MetricItem: self._resource_tagger.update_tags(self.baseurl, metric_item) # Update the metric itself - url = "{0}/{1}".format(self.baseurl, metric_item.id) + url = f"{self.baseurl}/{metric_item.id}" update_req = RequestFactory.Metric.update_req(metric_item) server_response = self.put_request(url, update_req) - logger.info("Updated metric item (ID: {0})".format(metric_item.id)) + logger.info(f"Updated metric item (ID: {metric_item.id})") return MetricItem.from_response(server_response.content, self.parent_srv.namespace)[0] diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py index 4433625f2..10d420ff7 100644 --- a/tableauserverclient/server/endpoint/permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/permissions_endpoint.py @@ -6,7 +6,7 @@ from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError -from typing import Callable, TYPE_CHECKING, List, Optional, Union +from typing import Callable, TYPE_CHECKING, Optional, Union from tableauserverclient.helpers.logging import logger @@ -25,7 +25,7 @@ class _PermissionsEndpoint(Endpoint): """ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> None: - super(_PermissionsEndpoint, self).__init__(parent_srv) + super().__init__(parent_srv) # owner_baseurl is the baseurl of the parent. The MUST be a lambda # since we don't know the full site URL until we sign in. If @@ -33,18 +33,18 @@ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> No self.owner_baseurl = owner_baseurl def __str__(self): - return "".format(self.owner_baseurl) + return f"" - def update(self, resource: TableauItem, permissions: List[PermissionsRule]) -> List[PermissionsRule]: - url = "{0}/{1}/permissions".format(self.owner_baseurl(), resource.id) + def update(self, resource: TableauItem, permissions: list[PermissionsRule]) -> list[PermissionsRule]: + url = f"{self.owner_baseurl()}/{resource.id}/permissions" update_req = RequestFactory.Permission.add_req(permissions) response = self.put_request(url, update_req) permissions = PermissionsRule.from_response(response.content, self.parent_srv.namespace) - logger.info("Updated permissions for resource {0}: {1}".format(resource.id, permissions)) + logger.info(f"Updated permissions for resource {resource.id}: {permissions}") return permissions - def delete(self, resource: TableauItem, rules: Union[PermissionsRule, List[PermissionsRule]]): + def delete(self, resource: TableauItem, rules: Union[PermissionsRule, list[PermissionsRule]]): # Delete is the only endpoint that doesn't take a list of rules # so let's fake it to keep it consistent # TODO that means we need error handling around the call @@ -54,7 +54,7 @@ def delete(self, resource: TableauItem, rules: Union[PermissionsRule, List[Permi for rule in rules: for capability, mode in rule.capabilities.items(): "/permissions/groups/group-id/capability-name/capability-mode" - url = "{0}/{1}/permissions/{2}/{3}/{4}/{5}".format( + url = "{}/{}/permissions/{}/{}/{}/{}".format( self.owner_baseurl(), resource.id, rule.grantee.tag_name + "s", @@ -63,13 +63,11 @@ def delete(self, resource: TableauItem, rules: Union[PermissionsRule, List[Permi mode, ) - logger.debug("Removing {0} permission for capability {1}".format(mode, capability)) + logger.debug(f"Removing {mode} permission for capability {capability}") self.delete_request(url) - logger.info( - "Deleted permission for {0} {1} item {2}".format(rule.grantee.tag_name, rule.grantee.id, resource.id) - ) + logger.info(f"Deleted permission for {rule.grantee.tag_name} {rule.grantee.id} item {resource.id}") def populate(self, item: TableauItem): if not item.id: @@ -80,12 +78,12 @@ def permission_fetcher(): return self._get_permissions(item) item._set_permissions(permission_fetcher) - logger.info("Populated permissions for item (ID: {0})".format(item.id)) + logger.info(f"Populated permissions for item (ID: {item.id})") def _get_permissions(self, item: TableauItem, req_options: Optional["RequestOptions"] = None): - url = "{0}/{1}/permissions".format(self.owner_baseurl(), item.id) + url = f"{self.owner_baseurl()}/{item.id}/permissions" server_response = self.get_request(url, req_options) permissions = PermissionsRule.from_response(server_response.content, self.parent_srv.namespace) - logger.info("Permissions for resource {0}: {1}".format(item.id, permissions)) + logger.info(f"Permissions for resource {item.id}: {permissions}") return permissions diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 565817e37..4d139fe66 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -7,7 +7,7 @@ from tableauserverclient.server import RequestFactory, RequestOptions from tableauserverclient.models import ProjectItem, PaginationItem, Resource -from typing import List, Optional, Tuple, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from tableauserverclient.server.query import QuerySet @@ -20,17 +20,17 @@ class Projects(QuerysetEndpoint[ProjectItem]): def __init__(self, parent_srv: "Server") -> None: - super(Projects, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl) @property def baseurl(self) -> str: - return "{0}/sites/{1}/projects".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/projects" @api(version="2.0") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[ProjectItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[ProjectItem], PaginationItem]: logger.info("Querying all projects on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -43,9 +43,9 @@ def delete(self, project_id: str) -> None: if not project_id: error = "Project ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, project_id) + url = f"{self.baseurl}/{project_id}" self.delete_request(url) - logger.info("Deleted single project (ID: {0})".format(project_id)) + logger.info(f"Deleted single project (ID: {project_id})") @api(version="2.0") def update(self, project_item: ProjectItem, samples: bool = False) -> ProjectItem: @@ -54,10 +54,10 @@ def update(self, project_item: ProjectItem, samples: bool = False) -> ProjectIte raise MissingRequiredFieldError(error) params = {"params": {RequestOptions.Field.PublishSamples: samples}} - url = "{0}/{1}".format(self.baseurl, project_item.id) + url = f"{self.baseurl}/{project_item.id}" update_req = RequestFactory.Project.update_req(project_item) server_response = self.put_request(url, update_req, XML_CONTENT_TYPE, params) - logger.info("Updated project item (ID: {0})".format(project_item.id)) + logger.info(f"Updated project item (ID: {project_item.id})") updated_project = ProjectItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_project @@ -66,11 +66,11 @@ def create(self, project_item: ProjectItem, samples: bool = False) -> ProjectIte params = {"params": {RequestOptions.Field.PublishSamples: samples}} url = self.baseurl if project_item._samples: - url = "{0}?publishSamples={1}".format(self.baseurl, project_item._samples) + url = f"{self.baseurl}?publishSamples={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] - logger.info("Created new project (ID: {0})".format(new_project.id)) + logger.info(f"Created new project (ID: {new_project.id})") return new_project @api(version="2.0") diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index 1894e3b8a..63c03b3e3 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -1,6 +1,7 @@ import abc import copy -from typing import Generic, Iterable, Optional, Protocol, Set, TypeVar, Union, TYPE_CHECKING, runtime_checkable +from typing import Generic, Optional, Protocol, TypeVar, Union, TYPE_CHECKING, runtime_checkable +from collections.abc import Iterable import urllib.parse from tableauserverclient.server.endpoint.endpoint import Endpoint, api @@ -24,7 +25,7 @@ class _ResourceTagger(Endpoint): # Add new tags to resource def _add_tags(self, baseurl, resource_id, tag_set): - url = "{0}/{1}/tags".format(baseurl, resource_id) + url = f"{baseurl}/{resource_id}/tags" add_req = RequestFactory.Tag.add_req(tag_set) try: @@ -39,7 +40,7 @@ def _add_tags(self, baseurl, resource_id, tag_set): # Delete a resource's tag by name def _delete_tag(self, baseurl, resource_id, tag_name): encoded_tag_name = urllib.parse.quote(tag_name) - url = "{0}/{1}/tags/{2}".format(baseurl, resource_id, encoded_tag_name) + url = f"{baseurl}/{resource_id}/tags/{encoded_tag_name}" try: self.delete_request(url) @@ -59,7 +60,7 @@ def update_tags(self, baseurl, resource_item): if add_set: resource_item.tags = self._add_tags(baseurl, resource_item.id, add_set) resource_item._initial_tags = copy.copy(resource_item.tags) - logger.info("Updated tags to {0}".format(resource_item.tags)) + logger.info(f"Updated tags to {resource_item.tags}") class Response(Protocol): @@ -68,8 +69,8 @@ class Response(Protocol): @runtime_checkable class Taggable(Protocol): - tags: Set[str] - _initial_tags: Set[str] + tags: set[str] + _initial_tags: set[str] @property def id(self) -> Optional[str]: @@ -95,14 +96,14 @@ def put_request(self, url, request) -> Response: def delete_request(self, url) -> None: pass - def add_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> Set[str]: + def add_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> set[str]: item_id = getattr(item, "id", item) if not isinstance(item_id, str): raise ValueError("ID not found.") if isinstance(tags, str): - tag_set = set([tags]) + tag_set = {tags} else: tag_set = set(tags) @@ -118,7 +119,7 @@ def delete_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> N raise ValueError("ID not found.") if isinstance(tags, str): - tag_set = set([tags]) + tag_set = {tags} else: tag_set = set(tags) @@ -158,9 +159,9 @@ def baseurl(self): return f"{self.parent_srv.baseurl}/tags" @api(version="3.9") - def batch_add(self, tags: Union[Iterable[str], str], content: content) -> Set[str]: + def batch_add(self, tags: Union[Iterable[str], str], content: content) -> set[str]: if isinstance(tags, str): - tag_set = set([tags]) + tag_set = {tags} else: tag_set = set(tags) @@ -170,9 +171,9 @@ def batch_add(self, tags: Union[Iterable[str], str], content: content) -> Set[st return TagItem.from_response(server_response.content, self.parent_srv.namespace) @api(version="3.9") - def batch_delete(self, tags: Union[Iterable[str], str], content: content) -> Set[str]: + def batch_delete(self, tags: Union[Iterable[str], str], content: content) -> set[str]: if isinstance(tags, str): - tag_set = set([tags]) + tag_set = {tags} else: tag_set = set(tags) diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index cfaee3324..4ed243b25 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -2,7 +2,7 @@ import logging import warnings from collections import namedtuple -from typing import TYPE_CHECKING, Callable, List, Optional, Tuple, Union +from typing import TYPE_CHECKING, Callable, Optional, Union from .endpoint import Endpoint, api, parameter_added_in from .exceptions import MissingRequiredFieldError @@ -22,14 +22,14 @@ class Schedules(Endpoint): @property def baseurl(self) -> str: - return "{0}/schedules".format(self.parent_srv.baseurl) + return f"{self.parent_srv.baseurl}/schedules" @property def siteurl(self) -> str: - return "{0}/sites/{1}/schedules".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/schedules" @api(version="2.3") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[ScheduleItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[ScheduleItem], PaginationItem]: logger.info("Querying all schedules") url = self.baseurl server_response = self.get_request(url, req_options) @@ -42,8 +42,8 @@ def get_by_id(self, schedule_id): if not schedule_id: error = "No Schedule ID provided" raise ValueError(error) - logger.info("Querying a single schedule by id ({})".format(schedule_id)) - url = "{0}/{1}".format(self.baseurl, schedule_id) + logger.info(f"Querying a single schedule by id ({schedule_id})") + url = f"{self.baseurl}/{schedule_id}" server_response = self.get_request(url) return ScheduleItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -52,9 +52,9 @@ def delete(self, schedule_id: str) -> None: if not schedule_id: error = "Schedule ID undefined" raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, schedule_id) + url = f"{self.baseurl}/{schedule_id}" self.delete_request(url) - logger.info("Deleted single schedule (ID: {0})".format(schedule_id)) + logger.info(f"Deleted single schedule (ID: {schedule_id})") @api(version="2.3") def update(self, schedule_item: ScheduleItem) -> ScheduleItem: @@ -62,10 +62,10 @@ def update(self, schedule_item: ScheduleItem) -> ScheduleItem: error = "Schedule item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}".format(self.baseurl, schedule_item.id) + url = f"{self.baseurl}/{schedule_item.id}" update_req = RequestFactory.Schedule.update_req(schedule_item) server_response = self.put_request(url, update_req) - logger.info("Updated schedule item (ID: {})".format(schedule_item.id)) + logger.info(f"Updated schedule item (ID: {schedule_item.id})") updated_schedule = copy.copy(schedule_item) return updated_schedule._parse_common_tags(server_response.content, self.parent_srv.namespace) @@ -79,7 +79,7 @@ def create(self, schedule_item: ScheduleItem) -> ScheduleItem: create_req = RequestFactory.Schedule.create_req(schedule_item) server_response = self.post_request(url, create_req) new_schedule = ScheduleItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Created new schedule (ID: {})".format(new_schedule.id)) + logger.info(f"Created new schedule (ID: {new_schedule.id})") return new_schedule @api(version="2.8") @@ -91,12 +91,12 @@ def add_to_schedule( datasource: Optional["DatasourceItem"] = None, flow: Optional["FlowItem"] = None, task_type: Optional[str] = None, - ) -> List[AddResponse]: + ) -> list[AddResponse]: # There doesn't seem to be a good reason to allow one item of each type? if workbook and datasource: warnings.warn("Passing in multiple items for add_to_schedule will be deprecated", PendingDeprecationWarning) - items: List[ - Tuple[str, Union[WorkbookItem, FlowItem, DatasourceItem], str, Callable[[Optional[str], str], bytes], str] + items: list[ + tuple[str, Union[WorkbookItem, FlowItem, DatasourceItem], str, Callable[[Optional[str], str], bytes], str] ] = [] if workbook is not None: @@ -133,13 +133,13 @@ def _add_to( item_task_type, ) -> AddResponse: id_ = resource.id - url = "{0}/{1}/{2}s".format(self.siteurl, schedule_id, type_) + url = f"{self.siteurl}/{schedule_id}/{type_}s" add_req = req_factory(id_, task_type=item_task_type) # type: ignore[call-arg, arg-type] response = self.put_request(url, add_req) error, warnings, task_created = ScheduleItem.parse_add_to_schedule_response(response, self.parent_srv.namespace) if task_created: - logger.info("Added {} to {} to schedule {}".format(type_, id_, schedule_id)) + logger.info(f"Added {type_} to {id_} to schedule {schedule_id}") if error is not None or warnings is not None: return AddResponse( diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index 26aaf2910..ab731c11b 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -21,11 +21,11 @@ def serverInfo(self): return self._info def __repr__(self): - return "".format(self.serverInfo) + return f"" @property def baseurl(self): - return "{0}/serverInfo".format(self.parent_srv.baseurl) + return f"{self.parent_srv.baseurl}/serverInfo" @api(version="2.4") def get(self): diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index dfec49ae1..0f3d25908 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -8,7 +8,7 @@ from tableauserverclient.helpers.logging import logger -from typing import TYPE_CHECKING, List, Optional, Tuple +from typing import TYPE_CHECKING, Optional if TYPE_CHECKING: from ..request_options import RequestOptions @@ -17,11 +17,11 @@ class Sites(Endpoint): @property def baseurl(self) -> str: - return "{0}/sites".format(self.parent_srv.baseurl) + return f"{self.parent_srv.baseurl}/sites" # Gets all sites @api(version="2.0") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[SiteItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[SiteItem], PaginationItem]: logger.info("Querying all sites on site") logger.info("Requires Server Admin permissions") url = self.baseurl @@ -40,8 +40,8 @@ def get_by_id(self, site_id: str) -> SiteItem: error = "You can only retrieve the site for which you are currently authenticated." raise ValueError(error) - logger.info("Querying single site (ID: {0})".format(site_id)) - url = "{0}/{1}".format(self.baseurl, site_id) + logger.info(f"Querying single site (ID: {site_id})") + url = f"{self.baseurl}/{site_id}" server_response = self.get_request(url) return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -52,8 +52,8 @@ def get_by_name(self, site_name: str) -> SiteItem: error = "Site Name undefined." raise ValueError(error) print("Note: You can only work with the site for which you are currently authenticated") - logger.info("Querying single site (Name: {0})".format(site_name)) - url = "{0}/{1}?key=name".format(self.baseurl, site_name) + logger.info(f"Querying single site (Name: {site_name})") + url = f"{self.baseurl}/{site_name}?key=name" print(self.baseurl, url) server_response = self.get_request(url) return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -68,9 +68,9 @@ def get_by_content_url(self, content_url: str) -> SiteItem: error = "You can only work with the site you are currently authenticated for" raise ValueError(error) - logger.info("Querying single site (Content URL: {0})".format(content_url)) + logger.info(f"Querying single site (Content URL: {content_url})") logger.debug("Querying other sites requires Server Admin permissions") - url = "{0}/{1}?key=contentUrl".format(self.baseurl, content_url) + url = f"{self.baseurl}/{content_url}?key=contentUrl" server_response = self.get_request(url) return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -90,10 +90,10 @@ def update(self, site_item: SiteItem) -> SiteItem: error = "You cannot set admin_mode to ContentOnly and also set a user quota" raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, site_item.id) + url = f"{self.baseurl}/{site_item.id}" update_req = RequestFactory.Site.update_req(site_item, self.parent_srv) server_response = self.put_request(url, update_req) - logger.info("Updated site item (ID: {0})".format(site_item.id)) + logger.info(f"Updated site item (ID: {site_item.id})") update_site = copy.copy(site_item) return update_site._parse_common_tags(server_response.content, self.parent_srv.namespace) @@ -103,13 +103,13 @@ def delete(self, site_id: str) -> None: if not site_id: error = "Site ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, site_id) + url = f"{self.baseurl}/{site_id}" if not site_id == self.parent_srv.site_id: error = "You can only delete the site you are currently authenticated for" raise ValueError(error) self.delete_request(url) self.parent_srv._clear_auth() - logger.info("Deleted single site (ID: {0}) and signed out".format(site_id)) + logger.info(f"Deleted single site (ID: {site_id}) and signed out") # Create new site @api(version="2.0") @@ -123,7 +123,7 @@ def create(self, site_item: SiteItem) -> SiteItem: create_req = RequestFactory.Site.create_req(site_item, self.parent_srv) server_response = self.post_request(url, create_req) new_site = SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Created new site (ID: {0})".format(new_site.id)) + logger.info(f"Created new site (ID: {new_site.id})") return new_site @api(version="3.5") @@ -131,7 +131,7 @@ def encrypt_extracts(self, site_id: str) -> None: if not site_id: error = "Site ID undefined." raise ValueError(error) - url = "{0}/{1}/encrypt-extracts".format(self.baseurl, site_id) + url = f"{self.baseurl}/{site_id}/encrypt-extracts" empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) @@ -140,7 +140,7 @@ def decrypt_extracts(self, site_id: str) -> None: if not site_id: error = "Site ID undefined." raise ValueError(error) - url = "{0}/{1}/decrypt-extracts".format(self.baseurl, site_id) + url = f"{self.baseurl}/{site_id}/decrypt-extracts" empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) @@ -149,7 +149,7 @@ def re_encrypt_extracts(self, site_id: str) -> None: if not site_id: error = "Site ID undefined." raise ValueError(error) - url = "{0}/{1}/reencrypt-extracts".format(self.baseurl, site_id) + url = f"{self.baseurl}/{site_id}/reencrypt-extracts" empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) diff --git a/tableauserverclient/server/endpoint/subscriptions_endpoint.py b/tableauserverclient/server/endpoint/subscriptions_endpoint.py index a9f2e7bf5..c9abc9b06 100644 --- a/tableauserverclient/server/endpoint/subscriptions_endpoint.py +++ b/tableauserverclient/server/endpoint/subscriptions_endpoint.py @@ -7,7 +7,7 @@ from tableauserverclient.helpers.logging import logger -from typing import List, Optional, TYPE_CHECKING, Tuple +from typing import Optional, TYPE_CHECKING if TYPE_CHECKING: from ..request_options import RequestOptions @@ -16,10 +16,10 @@ class Subscriptions(Endpoint): @property def baseurl(self) -> str: - return "{0}/sites/{1}/subscriptions".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/subscriptions" @api(version="2.3") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[SubscriptionItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[SubscriptionItem], PaginationItem]: logger.info("Querying all subscriptions for the site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -33,8 +33,8 @@ def get_by_id(self, subscription_id: str) -> SubscriptionItem: if not subscription_id: error = "No Subscription ID provided" raise ValueError(error) - logger.info("Querying a single subscription by id ({})".format(subscription_id)) - url = "{}/{}".format(self.baseurl, subscription_id) + logger.info(f"Querying a single subscription by id ({subscription_id})") + url = f"{self.baseurl}/{subscription_id}" server_response = self.get_request(url) return SubscriptionItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -43,7 +43,7 @@ def create(self, subscription_item: SubscriptionItem) -> SubscriptionItem: if not subscription_item: error = "No Susbcription provided" raise ValueError(error) - logger.info("Creating a subscription ({})".format(subscription_item)) + logger.info(f"Creating a subscription ({subscription_item})") url = self.baseurl create_req = RequestFactory.Subscription.create_req(subscription_item) server_response = self.post_request(url, create_req) @@ -54,17 +54,17 @@ def delete(self, subscription_id: str) -> None: if not subscription_id: error = "Subscription ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, subscription_id) + url = f"{self.baseurl}/{subscription_id}" self.delete_request(url) - logger.info("Deleted subscription (ID: {0})".format(subscription_id)) + logger.info(f"Deleted subscription (ID: {subscription_id})") @api(version="2.3") def update(self, subscription_item: SubscriptionItem) -> SubscriptionItem: if not subscription_item.id: error = "Subscription item missing ID. Subscription must be retrieved from server first." raise MissingRequiredFieldError(error) - url = "{0}/{1}".format(self.baseurl, subscription_item.id) + url = f"{self.baseurl}/{subscription_item.id}" update_req = RequestFactory.Subscription.update_req(subscription_item) server_response = self.put_request(url, update_req) - logger.info("Updated subscription item (ID: {0})".format(subscription_item.id)) + logger.info(f"Updated subscription item (ID: {subscription_item.id})") return SubscriptionItem.from_response(server_response.content, self.parent_srv.namespace)[0] diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index 36ef78c0a..120d3ba9c 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -1,5 +1,6 @@ import logging -from typing import Iterable, Set, Union +from typing import Union +from collections.abc import Iterable from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint from tableauserverclient.server.endpoint.endpoint import api, Endpoint @@ -15,14 +16,14 @@ class Tables(Endpoint, TaggingMixin[TableItem]): def __init__(self, parent_srv): - super(Tables, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "table") @property def baseurl(self): - return "{0}/sites/{1}/tables".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tables" @api(version="3.5") def get(self, req_options=None): @@ -39,8 +40,8 @@ def get_by_id(self, table_id): if not table_id: error = "table ID undefined." raise ValueError(error) - logger.info("Querying single table (ID: {0})".format(table_id)) - url = "{0}/{1}".format(self.baseurl, table_id) + logger.info(f"Querying single table (ID: {table_id})") + url = f"{self.baseurl}/{table_id}" server_response = self.get_request(url) return TableItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -49,9 +50,9 @@ def delete(self, table_id): if not table_id: error = "Database ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, table_id) + url = f"{self.baseurl}/{table_id}" self.delete_request(url) - logger.info("Deleted single table (ID: {0})".format(table_id)) + logger.info(f"Deleted single table (ID: {table_id})") @api(version="3.5") def update(self, table_item): @@ -59,10 +60,10 @@ def update(self, table_item): error = "table item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}".format(self.baseurl, table_item.id) + url = f"{self.baseurl}/{table_item.id}" update_req = RequestFactory.Table.update_req(table_item) server_response = self.put_request(url, update_req) - logger.info("Updated table item (ID: {0})".format(table_item.id)) + logger.info(f"Updated table item (ID: {table_item.id})") updated_table = TableItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_table @@ -80,10 +81,10 @@ def column_fetcher(): ) table_item._set_columns(column_fetcher) - logger.info("Populated columns for table (ID: {0}".format(table_item.id)) + logger.info(f"Populated columns for table (ID: {table_item.id}") def _get_columns_for_table(self, table_item, req_options=None): - url = "{0}/{1}/columns".format(self.baseurl, table_item.id) + url = f"{self.baseurl}/{table_item.id}/columns" server_response = self.get_request(url, req_options) columns = ColumnItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) @@ -91,12 +92,12 @@ def _get_columns_for_table(self, table_item, req_options=None): @api(version="3.5") def update_column(self, table_item, column_item): - url = "{0}/{1}/columns/{2}".format(self.baseurl, table_item.id, column_item.id) + url = f"{self.baseurl}/{table_item.id}/columns/{column_item.id}" update_req = RequestFactory.Column.update_req(column_item) server_response = self.put_request(url, update_req) column = ColumnItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Updated table item (ID: {0} & column item {1}".format(table_item.id, column_item.id)) + logger.info(f"Updated table item (ID: {table_item.id} & column item {column_item.id}") return column @api(version="3.5") @@ -128,7 +129,7 @@ def delete_dqw(self, item): self._data_quality_warnings.clear(item) @api(version="3.9") - def add_tags(self, item: Union[TableItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + def add_tags(self, item: Union[TableItem, str], tags: Union[Iterable[str], str]) -> set[str]: return super().add_tags(item, tags) @api(version="3.9") diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index a727a515f..eb82c43bc 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -1,5 +1,5 @@ import logging -from typing import List, Optional, Tuple, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from tableauserverclient.server.endpoint.endpoint import Endpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError @@ -15,7 +15,7 @@ class Tasks(Endpoint): @property def baseurl(self) -> str: - return "{0}/sites/{1}/tasks".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks" def __normalize_task_type(self, task_type: str) -> str: """ @@ -23,20 +23,20 @@ def __normalize_task_type(self, task_type: str) -> str: It is different than the tag "extractRefresh" used in the request body. """ if task_type == TaskItem.Type.ExtractRefresh: - return "{}es".format(task_type) + return f"{task_type}es" else: return task_type @api(version="2.6") def get( self, req_options: Optional["RequestOptions"] = None, task_type: str = TaskItem.Type.ExtractRefresh - ) -> Tuple[List[TaskItem], PaginationItem]: + ) -> tuple[list[TaskItem], PaginationItem]: if task_type == TaskItem.Type.DataAcceleration: self.parent_srv.assert_at_least_version("3.8", "Data Acceleration Tasks") logger.info("Querying all %s tasks for the site", task_type) - url = "{0}/{1}".format(self.baseurl, self.__normalize_task_type(task_type)) + url = f"{self.baseurl}/{self.__normalize_task_type(task_type)}" server_response = self.get_request(url, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) @@ -63,7 +63,7 @@ def create(self, extract_item: TaskItem) -> TaskItem: error = "No extract refresh provided" raise ValueError(error) logger.info("Creating an extract refresh %s", extract_item) - url = "{0}/{1}".format(self.baseurl, self.__normalize_task_type(TaskItem.Type.ExtractRefresh)) + url = f"{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 @@ -74,7 +74,7 @@ def run(self, task_item: TaskItem) -> bytes: error = "Task item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}/{2}/runNow".format( + url = "{}/{}/{}/runNow".format( self.baseurl, self.__normalize_task_type(TaskItem.Type.ExtractRefresh), task_item.id, @@ -92,6 +92,6 @@ def delete(self, task_id: str, task_type: str = TaskItem.Type.ExtractRefresh) -> if not task_id: error = "No Task ID provided" raise ValueError(error) - url = "{0}/{1}/{2}".format(self.baseurl, self.__normalize_task_type(task_type), task_id) + url = f"{self.baseurl}/{self.__normalize_task_type(task_type)}/{task_id}" self.delete_request(url) logger.info("Deleted single task (ID: %s)", task_id) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index c4b6418b7..793638396 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -1,6 +1,6 @@ import copy import logging -from typing import List, Optional, Tuple +from typing import Optional from tableauserverclient.server.query import QuerySet @@ -16,11 +16,11 @@ class Users(QuerysetEndpoint[UserItem]): @property def baseurl(self) -> str: - return "{0}/sites/{1}/users".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/users" # Gets all users @api(version="2.0") - def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[UserItem], PaginationItem]: + def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[UserItem], PaginationItem]: logger.info("Querying all users on site") if req_options is None: @@ -39,8 +39,8 @@ def get_by_id(self, user_id: str) -> UserItem: if not user_id: error = "User ID undefined." raise ValueError(error) - logger.info("Querying single user (ID: {0})".format(user_id)) - url = "{0}/{1}".format(self.baseurl, user_id) + logger.info(f"Querying single user (ID: {user_id})") + url = f"{self.baseurl}/{user_id}" server_response = self.get_request(url) return UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() @@ -51,10 +51,10 @@ def update(self, user_item: UserItem, password: Optional[str] = None) -> UserIte error = "User item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" update_req = RequestFactory.User.update_req(user_item, password) server_response = self.put_request(url, update_req) - logger.info("Updated user item (ID: {0})".format(user_item.id)) + logger.info(f"Updated user item (ID: {user_item.id})") updated_item = copy.copy(user_item) return updated_item._parse_common_tags(server_response.content, self.parent_srv.namespace) @@ -64,27 +64,27 @@ def remove(self, user_id: str, map_assets_to: Optional[str] = None) -> None: if not user_id: error = "User ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, user_id) + url = f"{self.baseurl}/{user_id}" if map_assets_to is not None: url += f"?mapAssetsTo={map_assets_to}" self.delete_request(url) - logger.info("Removed single user (ID: {0})".format(user_id)) + logger.info(f"Removed single user (ID: {user_id})") # Add new user to site @api(version="2.0") def add(self, user_item: UserItem) -> UserItem: url = self.baseurl - logger.info("Add user {}".format(user_item.name)) + logger.info(f"Add user {user_item.name}") add_req = RequestFactory.User.add_req(user_item) server_response = self.post_request(url, add_req) logger.info(server_response) new_user = UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() - logger.info("Added new user (ID: {0})".format(new_user.id)) + logger.info(f"Added new user (ID: {new_user.id})") return new_user # Add new users to site. This does not actually perform a bulk action, it's syntactic sugar @api(version="2.0") - def add_all(self, users: List[UserItem]): + def add_all(self, users: list[UserItem]): created = [] failed = [] for user in users: @@ -98,7 +98,7 @@ def add_all(self, users: List[UserItem]): # helping the user by parsing a file they could have used to add users through the UI # line format: Username [required], password, display name, license, admin, publish @api(version="2.0") - def create_from_file(self, filepath: str) -> Tuple[List[UserItem], List[Tuple[UserItem, ServerResponseError]]]: + def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[UserItem, ServerResponseError]]]: created = [] failed = [] if not filepath.find("csv"): @@ -133,10 +133,10 @@ def wb_pager(): def _get_wbs_for_user( self, user_item: UserItem, req_options: Optional[RequestOptions] = None - ) -> Tuple[List[WorkbookItem], PaginationItem]: - url = "{0}/{1}/workbooks".format(self.baseurl, user_item.id) + ) -> tuple[list[WorkbookItem], PaginationItem]: + url = f"{self.baseurl}/{user_item.id}/workbooks" server_response = self.get_request(url, req_options) - logger.info("Populated workbooks for user (ID: {0})".format(user_item.id)) + logger.info(f"Populated workbooks for user (ID: {user_item.id})") workbook_item = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) return workbook_item, pagination_item @@ -161,10 +161,10 @@ def groups_for_user_pager(): def _get_groups_for_user( self, user_item: UserItem, req_options: Optional[RequestOptions] = None - ) -> Tuple[List[GroupItem], PaginationItem]: - url = "{0}/{1}/groups".format(self.baseurl, user_item.id) + ) -> tuple[list[GroupItem], PaginationItem]: + url = f"{self.baseurl}/{user_item.id}/groups" server_response = self.get_request(url, req_options) - logger.info("Populated groups for user (ID: {0})".format(user_item.id)) + logger.info(f"Populated groups for user (ID: {user_item.id})") group_item = GroupItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) return group_item, pagination_item diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index f2ccf658e..3709fc41d 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -11,7 +11,8 @@ from tableauserverclient.helpers.logging import logger -from typing import Iterable, Iterator, List, Optional, Set, Tuple, TYPE_CHECKING, Union +from typing import Optional, TYPE_CHECKING, Union +from collections.abc import Iterable, Iterator if TYPE_CHECKING: from tableauserverclient.server.request_options import ( @@ -25,22 +26,22 @@ class Views(QuerysetEndpoint[ViewItem], TaggingMixin[ViewItem]): def __init__(self, parent_srv): - super(Views, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) # Used because populate_preview_image functionaliy requires workbook endpoint @property def siteurl(self) -> str: - return "{0}/sites/{1}".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}" @property def baseurl(self) -> str: - return "{0}/views".format(self.siteurl) + return f"{self.siteurl}/views" @api(version="2.2") def get( self, req_options: Optional["RequestOptions"] = None, usage: bool = False - ) -> Tuple[List[ViewItem], PaginationItem]: + ) -> tuple[list[ViewItem], PaginationItem]: logger.info("Querying all views on site") url = self.baseurl if usage: @@ -55,8 +56,8 @@ 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) + logger.info(f"Querying single view (ID: {view_id})") + url = f"{self.baseurl}/{view_id}" if usage: url += "?includeUsageStatistics=true" server_response = self.get_request(url) @@ -72,10 +73,10 @@ def image_fetcher(): return self._get_preview_for_view(view_item) view_item._set_preview_image(image_fetcher) - logger.info("Populated preview image for view (ID: {0})".format(view_item.id)) + logger.info(f"Populated preview image for view (ID: {view_item.id})") def _get_preview_for_view(self, view_item: ViewItem) -> bytes: - url = "{0}/workbooks/{1}/views/{2}/previewImage".format(self.siteurl, view_item.workbook_id, view_item.id) + url = f"{self.siteurl}/workbooks/{view_item.workbook_id}/views/{view_item.id}/previewImage" server_response = self.get_request(url) image = server_response.content return image @@ -90,10 +91,10 @@ def image_fetcher(): return self._get_view_image(view_item, req_options) view_item._set_image(image_fetcher) - logger.info("Populated image for view (ID: {0})".format(view_item.id)) + logger.info(f"Populated image for view (ID: {view_item.id})") def _get_view_image(self, view_item: ViewItem, req_options: Optional["ImageRequestOptions"]) -> bytes: - url = "{0}/{1}/image".format(self.baseurl, view_item.id) + url = f"{self.baseurl}/{view_item.id}/image" server_response = self.get_request(url, req_options) image = server_response.content return image @@ -108,10 +109,10 @@ def pdf_fetcher(): return self._get_view_pdf(view_item, req_options) view_item._set_pdf(pdf_fetcher) - logger.info("Populated pdf for view (ID: {0})".format(view_item.id)) + logger.info(f"Populated pdf for view (ID: {view_item.id})") def _get_view_pdf(self, view_item: ViewItem, req_options: Optional["PDFRequestOptions"]) -> bytes: - url = "{0}/{1}/pdf".format(self.baseurl, view_item.id) + url = f"{self.baseurl}/{view_item.id}/pdf" server_response = self.get_request(url, req_options) pdf = server_response.content return pdf @@ -126,10 +127,10 @@ def csv_fetcher(): return self._get_view_csv(view_item, req_options) view_item._set_csv(csv_fetcher) - logger.info("Populated csv for view (ID: {0})".format(view_item.id)) + logger.info(f"Populated csv for view (ID: {view_item.id})") def _get_view_csv(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"]) -> Iterator[bytes]: - url = "{0}/{1}/data".format(self.baseurl, view_item.id) + url = f"{self.baseurl}/{view_item.id}/data" with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: yield from server_response.iter_content(1024) @@ -144,10 +145,10 @@ def excel_fetcher(): return self._get_view_excel(view_item, req_options) view_item._set_excel(excel_fetcher) - logger.info("Populated excel for view (ID: {0})".format(view_item.id)) + logger.info(f"Populated excel for view (ID: {view_item.id})") def _get_view_excel(self, view_item: ViewItem, req_options: Optional["ExcelRequestOptions"]) -> Iterator[bytes]: - url = "{0}/{1}/crosstab/excel".format(self.baseurl, view_item.id) + url = f"{self.baseurl}/{view_item.id}/crosstab/excel" with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: yield from server_response.iter_content(1024) @@ -176,7 +177,7 @@ def update(self, view_item: ViewItem) -> ViewItem: return view_item @api(version="1.0") - def add_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + def add_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) -> set[str]: return super().add_tags(item, tags) @api(version="1.0") diff --git a/tableauserverclient/server/endpoint/virtual_connections_endpoint.py b/tableauserverclient/server/endpoint/virtual_connections_endpoint.py index f71db00cc..944b72502 100644 --- a/tableauserverclient/server/endpoint/virtual_connections_endpoint.py +++ b/tableauserverclient/server/endpoint/virtual_connections_endpoint.py @@ -1,7 +1,8 @@ from functools import partial import json from pathlib import Path -from typing import Iterable, List, Optional, Set, TYPE_CHECKING, Tuple, Union +from typing import Optional, TYPE_CHECKING, Union +from collections.abc import Iterable from tableauserverclient.models.connection_item import ConnectionItem from tableauserverclient.models.pagination_item import PaginationItem @@ -28,7 +29,7 @@ def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/virtualConnections" @api(version="3.18") - def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[VirtualConnectionItem], PaginationItem]: + def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[VirtualConnectionItem], PaginationItem]: server_response = self.get_request(self.baseurl, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) virtual_connections = VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace) @@ -44,7 +45,7 @@ def _connection_fetcher(): def _get_virtual_database_connections( self, virtual_connection: VirtualConnectionItem, req_options: Optional[RequestOptions] = None - ) -> Tuple[List[ConnectionItem], PaginationItem]: + ) -> tuple[list[ConnectionItem], PaginationItem]: server_response = self.get_request(f"{self.baseurl}/{virtual_connection.id}/connections", req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) @@ -83,7 +84,7 @@ def update(self, virtual_connection: VirtualConnectionItem) -> VirtualConnection @api(version="3.23") def get_revisions( self, virtual_connection: VirtualConnectionItem, req_options: Optional[RequestOptions] = None - ) -> Tuple[List[RevisionItem], PaginationItem]: + ) -> tuple[list[RevisionItem], PaginationItem]: server_response = self.get_request(f"{self.baseurl}/{virtual_connection.id}/revisions", req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, virtual_connection) @@ -159,7 +160,7 @@ def delete_permission(self, item, capability_item): @api(version="3.23") def add_tags( self, virtual_connection: Union[VirtualConnectionItem, str], tags: Union[Iterable[str], str] - ) -> Set[str]: + ) -> set[str]: return super().add_tags(virtual_connection, tags) @api(version="3.23") diff --git a/tableauserverclient/server/endpoint/webhooks_endpoint.py b/tableauserverclient/server/endpoint/webhooks_endpoint.py index 597f9c425..06643f99d 100644 --- a/tableauserverclient/server/endpoint/webhooks_endpoint.py +++ b/tableauserverclient/server/endpoint/webhooks_endpoint.py @@ -6,7 +6,7 @@ from tableauserverclient.helpers.logging import logger -from typing import List, Optional, TYPE_CHECKING, Tuple +from typing import Optional, TYPE_CHECKING if TYPE_CHECKING: from ..server import Server @@ -15,14 +15,14 @@ class Webhooks(Endpoint): def __init__(self, parent_srv: "Server") -> None: - super(Webhooks, self).__init__(parent_srv) + super().__init__(parent_srv) @property def baseurl(self) -> str: - return "{0}/sites/{1}/webhooks".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/webhooks" @api(version="3.6") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[WebhookItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[WebhookItem], PaginationItem]: logger.info("Querying all Webhooks on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -35,8 +35,8 @@ def get_by_id(self, webhook_id: str) -> WebhookItem: if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) - logger.info("Querying single webhook (ID: {0})".format(webhook_id)) - url = "{0}/{1}".format(self.baseurl, webhook_id) + logger.info(f"Querying single webhook (ID: {webhook_id})") + url = f"{self.baseurl}/{webhook_id}" server_response = self.get_request(url) return WebhookItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -45,9 +45,9 @@ def delete(self, webhook_id: str) -> None: if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, webhook_id) + url = f"{self.baseurl}/{webhook_id}" self.delete_request(url) - logger.info("Deleted single webhook (ID: {0})".format(webhook_id)) + logger.info(f"Deleted single webhook (ID: {webhook_id})") @api(version="3.6") def create(self, webhook_item: WebhookItem) -> WebhookItem: @@ -56,7 +56,7 @@ def create(self, webhook_item: WebhookItem) -> WebhookItem: server_response = self.post_request(url, create_req) new_webhook = WebhookItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Created new webhook (ID: {0})".format(new_webhook.id)) + logger.info(f"Created new webhook (ID: {new_webhook.id})") return new_webhook @api(version="3.6") @@ -64,7 +64,7 @@ def test(self, webhook_id: str): if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) - url = "{0}/{1}/test".format(self.baseurl, webhook_id) + url = f"{self.baseurl}/{webhook_id}/test" testOutcome = self.get_request(url) - logger.info("Testing webhook (ID: {0} returned {1})".format(webhook_id, testOutcome)) + logger.info(f"Testing webhook (ID: {webhook_id} returned {testOutcome})") return testOutcome diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index da6eda3de..5e4442b60 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -25,15 +25,11 @@ from tableauserverclient.server import RequestFactory from typing import ( - Iterable, - List, Optional, - Sequence, - Set, - Tuple, TYPE_CHECKING, Union, ) +from collections.abc import Iterable, Sequence if TYPE_CHECKING: from tableauserverclient.server import Server @@ -61,18 +57,18 @@ class Workbooks(QuerysetEndpoint[WorkbookItem], TaggingMixin[WorkbookItem]): def __init__(self, parent_srv: "Server") -> None: - super(Workbooks, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) return None @property def baseurl(self) -> str: - return "{0}/sites/{1}/workbooks".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/workbooks" # Get all workbooks on site @api(version="2.0") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[WorkbookItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[WorkbookItem], PaginationItem]: logger.info("Querying all workbooks on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -86,15 +82,15 @@ def get_by_id(self, workbook_id: str) -> WorkbookItem: if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) - logger.info("Querying single workbook (ID: {0})".format(workbook_id)) - url = "{0}/{1}".format(self.baseurl, workbook_id) + logger.info(f"Querying single workbook (ID: {workbook_id})") + url = f"{self.baseurl}/{workbook_id}" server_response = self.get_request(url) return WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="2.8") def refresh(self, workbook_item: Union[WorkbookItem, str]) -> JobItem: id_ = getattr(workbook_item, "id", workbook_item) - url = "{0}/{1}/refresh".format(self.baseurl, id_) + url = f"{self.baseurl}/{id_}/refresh" empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -107,10 +103,10 @@ def create_extract( workbook_item: WorkbookItem, encrypt: bool = False, includeAll: bool = True, - datasources: Optional[List["DatasourceItem"]] = None, + datasources: Optional[list["DatasourceItem"]] = None, ) -> JobItem: id_ = getattr(workbook_item, "id", workbook_item) - url = "{0}/{1}/createExtract?encrypt={2}".format(self.baseurl, id_, encrypt) + url = f"{self.baseurl}/{id_}/createExtract?encrypt={encrypt}" datasource_req = RequestFactory.Workbook.embedded_extract_req(includeAll, datasources) server_response = self.post_request(url, datasource_req) @@ -121,7 +117,7 @@ def create_extract( @api(version="3.3") def delete_extract(self, workbook_item: WorkbookItem, includeAll: bool = True, datasources=None) -> JobItem: id_ = getattr(workbook_item, "id", workbook_item) - url = "{0}/{1}/deleteExtract".format(self.baseurl, id_) + url = f"{self.baseurl}/{id_}/deleteExtract" datasource_req = RequestFactory.Workbook.embedded_extract_req(includeAll, datasources) server_response = self.post_request(url, datasource_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -133,9 +129,9 @@ def delete(self, workbook_id: str) -> None: if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, workbook_id) + url = f"{self.baseurl}/{workbook_id}" self.delete_request(url) - logger.info("Deleted single workbook (ID: {0})".format(workbook_id)) + logger.info(f"Deleted single workbook (ID: {workbook_id})") # Update workbook @api(version="2.0") @@ -152,27 +148,25 @@ def update( self.update_tags(workbook_item) # Update the workbook itself - url = "{0}/{1}".format(self.baseurl, workbook_item.id) + url = f"{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)) + logger.info(f"Updated workbook item (ID: {workbook_item.id})") updated_workbook = copy.copy(workbook_item) return updated_workbook._parse_common_tags(server_response.content, self.parent_srv.namespace) # Update workbook_connection @api(version="2.3") def update_connection(self, workbook_item: WorkbookItem, connection_item: ConnectionItem) -> ConnectionItem: - url = "{0}/{1}/connections/{2}".format(self.baseurl, workbook_item.id, connection_item.id) + url = f"{self.baseurl}/{workbook_item.id}/connections/{connection_item.id}" update_req = RequestFactory.Connection.update_req(connection_item) server_response = self.put_request(url, update_req) connection = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info( - "Updated workbook item (ID: {0} & connection item {1})".format(workbook_item.id, connection_item.id) - ) + logger.info(f"Updated workbook item (ID: {workbook_item.id} & connection item {connection_item.id})") return connection # Download workbook contents with option of passing in filepath @@ -199,14 +193,14 @@ def populate_views(self, workbook_item: WorkbookItem, usage: bool = False) -> No error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) - def view_fetcher() -> List[ViewItem]: + def view_fetcher() -> list[ViewItem]: return self._get_views_for_workbook(workbook_item, usage) workbook_item._set_views(view_fetcher) - logger.info("Populated views for workbook (ID: {0})".format(workbook_item.id)) + logger.info(f"Populated views for workbook (ID: {workbook_item.id})") - def _get_views_for_workbook(self, workbook_item: WorkbookItem, usage: bool) -> List[ViewItem]: - url = "{0}/{1}/views".format(self.baseurl, workbook_item.id) + def _get_views_for_workbook(self, workbook_item: WorkbookItem, usage: bool) -> list[ViewItem]: + url = f"{self.baseurl}/{workbook_item.id}/views" if usage: url += "?includeUsageStatistics=true" server_response = self.get_request(url) @@ -228,12 +222,12 @@ def connection_fetcher(): return self._get_workbook_connections(workbook_item) workbook_item._set_connections(connection_fetcher) - logger.info("Populated connections for workbook (ID: {0})".format(workbook_item.id)) + logger.info(f"Populated connections for workbook (ID: {workbook_item.id})") def _get_workbook_connections( self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None - ) -> List[ConnectionItem]: - url = "{0}/{1}/connections".format(self.baseurl, workbook_item.id) + ) -> list[ConnectionItem]: + url = f"{self.baseurl}/{workbook_item.id}/connections" server_response = self.get_request(url, req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) return connections @@ -249,10 +243,10 @@ def pdf_fetcher() -> bytes: return self._get_wb_pdf(workbook_item, req_options) workbook_item._set_pdf(pdf_fetcher) - logger.info("Populated pdf for workbook (ID: {0})".format(workbook_item.id)) + logger.info(f"Populated pdf for workbook (ID: {workbook_item.id})") def _get_wb_pdf(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"]) -> bytes: - url = "{0}/{1}/pdf".format(self.baseurl, workbook_item.id) + url = f"{self.baseurl}/{workbook_item.id}/pdf" server_response = self.get_request(url, req_options) pdf = server_response.content return pdf @@ -267,10 +261,10 @@ def pptx_fetcher() -> bytes: return self._get_wb_pptx(workbook_item, req_options) workbook_item._set_powerpoint(pptx_fetcher) - logger.info("Populated powerpoint for workbook (ID: {0})".format(workbook_item.id)) + logger.info(f"Populated powerpoint for workbook (ID: {workbook_item.id})") def _get_wb_pptx(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"]) -> bytes: - url = "{0}/{1}/powerpoint".format(self.baseurl, workbook_item.id) + url = f"{self.baseurl}/{workbook_item.id}/powerpoint" server_response = self.get_request(url, req_options) pptx = server_response.content return pptx @@ -286,10 +280,10 @@ def image_fetcher() -> bytes: return self._get_wb_preview_image(workbook_item) workbook_item._set_preview_image(image_fetcher) - logger.info("Populated preview image for workbook (ID: {0})".format(workbook_item.id)) + logger.info(f"Populated preview image for workbook (ID: {workbook_item.id})") def _get_wb_preview_image(self, workbook_item: WorkbookItem) -> bytes: - url = "{0}/{1}/previewImage".format(self.baseurl, workbook_item.id) + url = f"{self.baseurl}/{workbook_item.id}/previewImage" server_response = self.get_request(url) preview_image = server_response.content return preview_image @@ -322,7 +316,7 @@ def publish( if isinstance(file, (str, os.PathLike)): if not os.path.isfile(file): error = "File path does not lead to an existing file." - raise IOError(error) + raise OSError(error) filename = os.path.basename(file) file_extension = os.path.splitext(filename)[1][1:] @@ -346,12 +340,12 @@ def publish( elif file_type == "xml": file_extension = "twb" else: - error = "Unsupported file type {}!".format(file_type) + error = f"Unsupported file type {file_type}!" raise ValueError(error) # Generate filename for file object. # This is needed when publishing the workbook in a single request - filename = "{}.{}".format(workbook_item.name, file_extension) + filename = f"{workbook_item.name}.{file_extension}" file_size = get_file_object_size(file) else: @@ -362,30 +356,30 @@ def publish( raise ValueError(error) # Construct the url with the defined mode - url = "{0}?workbookType={1}".format(self.baseurl, file_extension) + url = f"{self.baseurl}?workbookType={file_extension}" if mode == self.parent_srv.PublishMode.Overwrite: - url += "&{0}=true".format(mode.lower()) + url += f"&{mode.lower()}=true" elif mode == self.parent_srv.PublishMode.Append: error = "Workbooks cannot be appended." raise ValueError(error) if as_job: - url += "&{0}=true".format("asJob") + url += "&{}=true".format("asJob") if skip_connection_check: - url += "&{0}=true".format("skipConnectionCheck") + url += "&{}=true".format("skipConnectionCheck") # Determine if chunking is required (64MB is the limit for single upload method) if file_size >= FILESIZE_LIMIT: - logger.info("Publishing {0} to server with chunking method (workbook over 64MB)".format(workbook_item.name)) + logger.info(f"Publishing {workbook_item.name} to server with chunking method (workbook over 64MB)") upload_session_id = self.parent_srv.fileuploads.upload(file) - url = "{0}&uploadSessionId={1}".format(url, upload_session_id) + url = f"{url}&uploadSessionId={upload_session_id}" xml_request, content_type = RequestFactory.Workbook.publish_req_chunked( workbook_item, connections=connections, ) else: - logger.info("Publishing {0} to server".format(filename)) + logger.info(f"Publishing {filename} to server") if isinstance(file, (str, Path)): with open(file, "rb") as f: @@ -403,7 +397,7 @@ def publish( file_contents, connections=connections, ) - logger.debug("Request xml: {0} ".format(redact_xml(xml_request[:1000]))) + logger.debug(f"Request xml: {redact_xml(xml_request[:1000])} ") # Send the publishing request to server try: @@ -415,11 +409,11 @@ def publish( if as_job: new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Published {0} (JOB_ID: {1}".format(workbook_item.name, new_job.id)) + logger.info(f"Published {workbook_item.name} (JOB_ID: {new_job.id}") return new_job else: new_workbook = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Published {0} (ID: {1})".format(workbook_item.name, new_workbook.id)) + logger.info(f"Published {workbook_item.name} (ID: {new_workbook.id})") return new_workbook # Populate workbook item's revisions @@ -433,12 +427,12 @@ def revisions_fetcher(): return self._get_workbook_revisions(workbook_item) workbook_item._set_revisions(revisions_fetcher) - logger.info("Populated revisions for workbook (ID: {0})".format(workbook_item.id)) + logger.info(f"Populated revisions for workbook (ID: {workbook_item.id})") def _get_workbook_revisions( self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None - ) -> List[RevisionItem]: - url = "{0}/{1}/revisions".format(self.baseurl, workbook_item.id) + ) -> list[RevisionItem]: + url = f"{self.baseurl}/{workbook_item.id}/revisions" server_response = self.get_request(url, req_options) revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, workbook_item) return revisions @@ -456,9 +450,9 @@ def download_revision( error = "Workbook ID undefined." raise ValueError(error) if revision_number is None: - url = "{0}/{1}/content".format(self.baseurl, workbook_id) + url = f"{self.baseurl}/{workbook_id}/content" else: - url = "{0}/{1}/revisions/{2}/content".format(self.baseurl, workbook_id, revision_number) + url = f"{self.baseurl}/{workbook_id}/revisions/{revision_number}/content" if not include_extract: url += "?includeExtract=False" @@ -480,9 +474,7 @@ def download_revision( f.write(chunk) return_path = os.path.abspath(download_path) - logger.info( - "Downloaded workbook revision {0} to {1} (ID: {2})".format(revision_number, return_path, workbook_id) - ) + logger.info(f"Downloaded workbook revision {revision_number} to {return_path} (ID: {workbook_id})") return return_path @api(version="2.3") @@ -492,17 +484,17 @@ def delete_revision(self, workbook_id: str, revision_number: str) -> None: url = "/".join([self.baseurl, workbook_id, "revisions", revision_number]) self.delete_request(url) - logger.info("Deleted single workbook revision (ID: {0}) (Revision: {1})".format(workbook_id, revision_number)) + logger.info(f"Deleted single workbook revision (ID: {workbook_id}) (Revision: {revision_number})") # a convenience method @api(version="2.8") def schedule_extract_refresh( self, schedule_id: str, item: WorkbookItem - ) -> List["AddResponse"]: # actually should return a task + ) -> list["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, workbook=item) @api(version="1.0") - def add_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + def add_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> set[str]: return super().add_tags(item, tags) @api(version="1.0") diff --git a/tableauserverclient/server/filter.py b/tableauserverclient/server/filter.py index b936ceb92..fd90e281f 100644 --- a/tableauserverclient/server/filter.py +++ b/tableauserverclient/server/filter.py @@ -1,7 +1,7 @@ from .request_options import RequestOptions -class Filter(object): +class Filter: def __init__(self, field, operator, value): self.field = field self.operator = operator @@ -16,7 +16,7 @@ def __str__(self): # 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) + return f"{self.field}:{self.operator}:{value_string}" @property def value(self): diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index ca9d83872..e6d261b61 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -1,6 +1,7 @@ import copy from functools import partial -from typing import Iterable, Iterator, List, Optional, Protocol, Tuple, TypeVar, Union, runtime_checkable +from typing import Optional, Protocol, TypeVar, Union, runtime_checkable +from collections.abc import Iterable, Iterator from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.request_options import RequestOptions @@ -11,14 +12,12 @@ @runtime_checkable class Endpoint(Protocol[T]): - def get(self, req_options: Optional[RequestOptions]) -> Tuple[List[T], PaginationItem]: - ... + def get(self, req_options: Optional[RequestOptions]) -> tuple[list[T], PaginationItem]: ... @runtime_checkable class CallableEndpoint(Protocol[T]): - def __call__(self, __req_options: Optional[RequestOptions], **kwargs) -> Tuple[List[T], PaginationItem]: - ... + def __call__(self, __req_options: Optional[RequestOptions], **kwargs) -> tuple[list[T], PaginationItem]: ... class Pager(Iterable[T]): @@ -27,7 +26,7 @@ class Pager(Iterable[T]): Supports all `RequestOptions` including starting on any page. Also used by models to load sub-models (users in a group, views in a workbook, etc) by passing a different endpoint. - Will loop over anything that returns (List[ModelItem], PaginationItem). + Will loop over anything that returns (list[ModelItem], PaginationItem). """ def __init__( diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index bbca612e9..e72b29ab2 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -1,6 +1,7 @@ from collections.abc import Sized from itertools import count -from typing import Iterable, Iterator, List, Optional, Protocol, Tuple, TYPE_CHECKING, TypeVar, overload +from typing import Optional, Protocol, TYPE_CHECKING, TypeVar, overload +from collections.abc import Iterable, Iterator from tableauserverclient.config import config from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.filter import Filter @@ -37,7 +38,7 @@ class QuerySet(Iterable[T], Sized): def __init__(self, model: "QuerysetEndpoint[T]", page_size: Optional[int] = None) -> None: self.model = model self.request_options = RequestOptions(pagesize=page_size or config.PAGE_SIZE) - self._result_cache: List[T] = [] + self._result_cache: list[T] = [] self._pagination_item = PaginationItem() def __iter__(self: Self) -> Iterator[T]: @@ -56,12 +57,10 @@ def __iter__(self: Self) -> Iterator[T]: return @overload - def __getitem__(self: Self, k: Slice) -> List[T]: - ... + def __getitem__(self: Self, k: Slice) -> list[T]: ... @overload - def __getitem__(self: Self, k: int) -> T: - ... + def __getitem__(self: Self, k: int) -> T: ... def __getitem__(self, k): page = self.page_number @@ -160,22 +159,22 @@ def paginate(self: Self, **kwargs) -> Self: return self @staticmethod - def _parse_shorthand_filter(key: str) -> Tuple[str, str]: + def _parse_shorthand_filter(key: str) -> tuple[str, str]: tokens = key.split("__", 1) if len(tokens) == 1: operator = RequestOptions.Operator.Equals else: operator = tokens[1] if operator not in RequestOptions.Operator.__dict__.values(): - raise ValueError("Operator `{}` is not valid.".format(operator)) + raise ValueError(f"Operator `{operator}` is not valid.") field = to_camel_case(tokens[0]) if field not in RequestOptions.Field.__dict__.values(): - raise ValueError("Field name `{}` is not valid.".format(field)) + raise ValueError(f"Field name `{field}` is not valid.") return (field, operator) @staticmethod - def _parse_shorthand_sort(key: str) -> Tuple[str, str]: + def _parse_shorthand_sort(key: str) -> tuple[str, str]: direction = RequestOptions.Direction.Asc if key.startswith("-"): direction = RequestOptions.Direction.Desc diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 96fa14680..f7bd139d7 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1,5 +1,6 @@ import xml.etree.ElementTree as ET -from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, TypeVar, TYPE_CHECKING, Union +from typing import Any, Callable, Optional, TypeVar, TYPE_CHECKING, Union +from collections.abc import Iterable from typing_extensions import ParamSpec @@ -15,7 +16,7 @@ # this file could be largely replaced if we were willing to import the huge file from generateDS -def _add_multipart(parts: Dict) -> Tuple[Any, str]: +def _add_multipart(parts: dict) -> tuple[Any, str]: mime_multipart_parts = list() for name, (filename, data, content_type) in parts.items(): multipart_part = RequestField(name=name, data=data, filename=filename) @@ -80,7 +81,7 @@ def _add_credentials_element(parent_element, connection_credentials): credentials_element.attrib["oAuth"] = "true" -class AuthRequest(object): +class AuthRequest: def signin_req(self, auth_item): xml_request = ET.Element("tsRequest") @@ -104,7 +105,7 @@ def switch_req(self, site_content_url): return ET.tostring(xml_request) -class ColumnRequest(object): +class ColumnRequest: def update_req(self, column_item): xml_request = ET.Element("tsRequest") column_element = ET.SubElement(xml_request, "column") @@ -115,7 +116,7 @@ def update_req(self, column_item): return ET.tostring(xml_request) -class DataAlertRequest(object): +class DataAlertRequest: def add_user_to_alert(self, alert_item: "DataAlertItem", user_id: str) -> bytes: xml_request = ET.Element("tsRequest") user_element = ET.SubElement(xml_request, "user") @@ -140,7 +141,7 @@ def update_req(self, alert_item: "DataAlertItem") -> bytes: return ET.tostring(xml_request) -class DatabaseRequest(object): +class DatabaseRequest: def update_req(self, database_item): xml_request = ET.Element("tsRequest") database_element = ET.SubElement(xml_request, "database") @@ -159,7 +160,7 @@ def update_req(self, database_item): return ET.tostring(xml_request) -class DatasourceRequest(object): +class DatasourceRequest: def _generate_xml(self, datasource_item: DatasourceItem, connection_credentials=None, connections=None): xml_request = ET.Element("tsRequest") datasource_element = ET.SubElement(xml_request, "datasource") @@ -244,7 +245,7 @@ def publish_req_chunked(self, datasource_item, connection_credentials=None, conn return _add_multipart(parts) -class DQWRequest(object): +class DQWRequest: def add_req(self, dqw_item): xml_request = ET.Element("tsRequest") dqw_element = ET.SubElement(xml_request, "dataQualityWarning") @@ -274,7 +275,7 @@ def update_req(self, dqw_item): return ET.tostring(xml_request) -class FavoriteRequest(object): +class FavoriteRequest: def add_request(self, id_: Optional[str], target_type: str, label: Optional[str]) -> bytes: """ @@ -329,7 +330,7 @@ def add_workbook_req(self, id_: Optional[str], name: Optional[str]) -> bytes: return self.add_request(id_, Resource.Workbook, name) -class FileuploadRequest(object): +class FileuploadRequest: def chunk_req(self, chunk): parts = { "request_payload": ("", "", "text/xml"), @@ -338,8 +339,8 @@ def chunk_req(self, chunk): return _add_multipart(parts) -class FlowRequest(object): - def _generate_xml(self, flow_item: "FlowItem", connections: Optional[List["ConnectionItem"]] = None) -> bytes: +class FlowRequest: + def _generate_xml(self, flow_item: "FlowItem", connections: Optional[list["ConnectionItem"]] = None) -> bytes: xml_request = ET.Element("tsRequest") flow_element = ET.SubElement(xml_request, "flow") if flow_item.name is not None: @@ -370,8 +371,8 @@ def publish_req( flow_item: "FlowItem", filename: str, file_contents: bytes, - connections: Optional[List["ConnectionItem"]] = None, - ) -> Tuple[Any, str]: + connections: Optional[list["ConnectionItem"]] = None, + ) -> tuple[Any, str]: xml_request = self._generate_xml(flow_item, connections) parts = { @@ -380,14 +381,14 @@ def publish_req( } return _add_multipart(parts) - def publish_req_chunked(self, flow_item, connections=None) -> Tuple[Any, str]: + def publish_req_chunked(self, flow_item, connections=None) -> tuple[Any, str]: xml_request = self._generate_xml(flow_item, connections) parts = {"request_payload": ("", xml_request, "text/xml")} return _add_multipart(parts) -class GroupRequest(object): +class GroupRequest: def add_user_req(self, user_id: str) -> bytes: xml_request = ET.Element("tsRequest") user_element = ET.SubElement(xml_request, "user") @@ -477,7 +478,7 @@ def update_req( return ET.tostring(xml_request) -class PermissionRequest(object): +class PermissionRequest: def add_req(self, rules: Iterable[PermissionsRule]) -> bytes: xml_request = ET.Element("tsRequest") permissions_element = ET.SubElement(xml_request, "permissions") @@ -499,7 +500,7 @@ def _add_all_capabilities(self, capabilities_element, capabilities_map): capability_element.attrib["mode"] = mode -class ProjectRequest(object): +class ProjectRequest: def update_req(self, project_item: "ProjectItem") -> bytes: xml_request = ET.Element("tsRequest") project_element = ET.SubElement(xml_request, "project") @@ -530,7 +531,7 @@ def create_req(self, project_item: "ProjectItem") -> bytes: return ET.tostring(xml_request) -class ScheduleRequest(object): +class ScheduleRequest: def create_req(self, schedule_item): xml_request = ET.Element("tsRequest") schedule_element = ET.SubElement(xml_request, "schedule") @@ -609,7 +610,7 @@ def add_flow_req(self, id_: Optional[str], task_type: str = TaskItem.Type.RunFlo return self._add_to_req(id_, "flow", task_type) -class SiteRequest(object): +class SiteRequest: def update_req(self, site_item: "SiteItem", parent_srv: Optional["Server"] = None): xml_request = ET.Element("tsRequest") site_element = ET.SubElement(xml_request, "site") @@ -848,7 +849,7 @@ def set_versioned_flow_attributes(self, flows_all, flows_edit, flows_schedule, p warnings.warn("In version 3.10 and earlier there is only one option: FlowsEnabled") -class TableRequest(object): +class TableRequest: def update_req(self, table_item): xml_request = ET.Element("tsRequest") table_element = ET.SubElement(xml_request, "table") @@ -871,7 +872,7 @@ def update_req(self, table_item): content_types = Iterable[Union["ColumnItem", "DatabaseItem", "DatasourceItem", "FlowItem", "TableItem", "WorkbookItem"]] -class TagRequest(object): +class TagRequest: def add_req(self, tag_set): xml_request = ET.Element("tsRequest") tags_element = ET.SubElement(xml_request, "tags") @@ -881,7 +882,7 @@ def add_req(self, tag_set): return ET.tostring(xml_request) @_tsrequest_wrapped - def batch_create(self, element: ET.Element, tags: Set[str], content: content_types) -> bytes: + def batch_create(self, element: ET.Element, tags: set[str], content: content_types) -> bytes: tag_batch = ET.SubElement(element, "tagBatch") tags_element = ET.SubElement(tag_batch, "tags") for tag in tags: @@ -897,7 +898,7 @@ def batch_create(self, element: ET.Element, tags: Set[str], content: content_typ return ET.tostring(element) -class UserRequest(object): +class UserRequest: def update_req(self, user_item: UserItem, password: Optional[str]) -> bytes: xml_request = ET.Element("tsRequest") user_element = ET.SubElement(xml_request, "user") @@ -931,7 +932,7 @@ def add_req(self, user_item: UserItem) -> bytes: return ET.tostring(xml_request) -class WorkbookRequest(object): +class WorkbookRequest: def _generate_xml( self, workbook_item, @@ -995,9 +996,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.") @@ -1075,7 +1076,7 @@ def embedded_extract_req( datasource_element.attrib["id"] = id_ -class Connection(object): +class Connection: @_tsrequest_wrapped def update_req(self, xml_request: ET.Element, connection_item: "ConnectionItem") -> None: connection_element = ET.SubElement(xml_request, "connection") @@ -1098,7 +1099,7 @@ def update_req(self, xml_request: ET.Element, connection_item: "ConnectionItem") connection_element.attrib["queryTaggingEnabled"] = str(connection_item.query_tagging).lower() -class TaskRequest(object): +class TaskRequest: @_tsrequest_wrapped def run_req(self, xml_request: ET.Element, task_item: Any) -> None: # Send an empty tsRequest @@ -1137,7 +1138,7 @@ def create_extract_req(self, xml_request: ET.Element, extract_item: "TaskItem") return ET.tostring(xml_request) -class FlowTaskRequest(object): +class FlowTaskRequest: @_tsrequest_wrapped def create_flow_task_req(self, xml_request: ET.Element, flow_item: "TaskItem") -> bytes: flow_element = ET.SubElement(xml_request, "runFlow") @@ -1171,7 +1172,7 @@ def create_flow_task_req(self, xml_request: ET.Element, flow_item: "TaskItem") - return ET.tostring(xml_request) -class SubscriptionRequest(object): +class SubscriptionRequest: @_tsrequest_wrapped def create_req(self, xml_request: ET.Element, subscription_item: "SubscriptionItem") -> bytes: subscription_element = ET.SubElement(xml_request, "subscription") @@ -1235,13 +1236,13 @@ def update_req(self, xml_request: ET.Element, subscription_item: "SubscriptionIt return ET.tostring(xml_request) -class EmptyRequest(object): +class EmptyRequest: @_tsrequest_wrapped def empty_req(self, xml_request: ET.Element) -> None: pass -class WebhookRequest(object): +class WebhookRequest: @_tsrequest_wrapped def create_req(self, xml_request: ET.Element, webhook_item: "WebhookItem") -> bytes: webhook = ET.SubElement(xml_request, "webhook") @@ -1287,7 +1288,7 @@ def update_req(self, xml_request: ET.Element, metric_item: MetricItem) -> bytes: return ET.tostring(xml_request) -class CustomViewRequest(object): +class CustomViewRequest: @_tsrequest_wrapped def update_req(self, xml_request: ET.Element, custom_view_item: CustomViewItem): updating_element = ET.SubElement(xml_request, "customView") @@ -1415,7 +1416,7 @@ def publish(self, xml_request: ET.Element, virtual_connection: VirtualConnection return ET.tostring(xml_request) -class RequestFactory(object): +class RequestFactory: Auth = AuthRequest() Connection = Connection() Column = ColumnRequest() diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index ddb45834d..fedf3ab45 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -9,12 +9,12 @@ from tableauserverclient.helpers.logging import logger -class RequestOptionsBase(object): +class RequestOptionsBase: # This method is used if server api version is below 3.7 (2020.1) def apply_query_params(self, url): try: params = self.get_query_params() - params_list = ["{}={}".format(k, v) for (k, v) in params.items()] + params_list = [f"{k}={v}" for (k, v) in params.items()] logger.debug("Applying options to request: <%s(%s)>", self.__class__.__name__, ",".join(params_list)) @@ -22,7 +22,7 @@ def apply_query_params(self, url): url, existing_params = url.split("?") params_list.append(existing_params) - return "{0}?{1}".format(url, "&".join(params_list)) + return "{}?{}".format(url, "&".join(params_list)) except NotImplementedError: raise @@ -183,7 +183,7 @@ def _append_view_filters(self, params) -> None: class CSVRequestOptions(_FilterOptionsBase): def __init__(self, maxage=-1): - super(CSVRequestOptions, self).__init__() + super().__init__() self.max_age = maxage @property @@ -233,7 +233,7 @@ class Resolution: High = "high" def __init__(self, imageresolution=None, maxage=-1): - super(ImageRequestOptions, self).__init__() + super().__init__() self.image_resolution = imageresolution self.max_age = maxage @@ -278,7 +278,7 @@ class Orientation: Landscape = "landscape" def __init__(self, page_type=None, orientation=None, maxage=-1, viz_height=None, viz_width=None): - super(PDFRequestOptions, self).__init__() + super().__init__() self.page_type = page_type self.orientation = orientation self.max_age = maxage diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index e563a7138..dab5911db 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -58,7 +58,7 @@ default_server_version = "2.4" # first version that dropped the legacy auth endpoint -class Server(object): +class Server: class PublishMode: Append = "Append" Overwrite = "Overwrite" @@ -130,7 +130,7 @@ def validate_connection_settings(self): raise ValueError("Server connection settings not valid", req_ex) def __repr__(self): - return "".format(self.baseurl, self.server_info.serverInfo) + return f"" def add_http_options(self, options_dict: dict): try: @@ -142,7 +142,7 @@ def add_http_options(self, options_dict: dict): # expected errors on invalid input: # 'set' object has no attribute 'keys', 'list' object has no attribute 'keys' # TypeError: cannot convert dictionary update sequence element #0 to a sequence (input is a tuple) - raise ValueError("Invalid http options given: {}".format(options_dict)) + raise ValueError(f"Invalid http options given: {options_dict}") def clear_http_options(self): self._http_options = dict() @@ -176,15 +176,15 @@ def _determine_highest_version(self): old_version = self.version version = self.server_info.get().rest_api_version except ServerInfoEndpointNotFoundError as e: - logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) + logger.info(f"Could not get version info from server: {e.__class__}{e}") version = self._get_legacy_version() except EndpointUnavailableError as e: - logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) + logger.info(f"Could not get version info from server: {e.__class__}{e}") version = self._get_legacy_version() except Exception as e: - logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) + logger.info(f"Could not get version info from server: {e.__class__}{e}") version = None - logger.info("versions: {}, {}".format(version, old_version)) + logger.info(f"versions: {version}, {old_version}") return version or old_version def use_server_version(self): @@ -201,12 +201,12 @@ def check_at_least_version(self, target: str): def assert_at_least_version(self, comparison: str, reason: str): if not self.check_at_least_version(comparison): - error = "{} is not available in API version {}. Requires {}".format(reason, self.version, comparison) + error = f"{reason} is not available in API version {self.version}. Requires {comparison}" raise EndpointUnavailableError(error) @property def baseurl(self): - return "{0}/api/{1}".format(self._server_address, str(self.version)) + return f"{self._server_address}/api/{str(self.version)}" @property def namespace(self): diff --git a/tableauserverclient/server/sort.py b/tableauserverclient/server/sort.py index 2d6bc030a..839a8c8db 100644 --- a/tableauserverclient/server/sort.py +++ b/tableauserverclient/server/sort.py @@ -1,7 +1,7 @@ -class Sort(object): +class Sort: def __init__(self, field, direction): self.field = field self.direction = direction def __str__(self): - return "{0}:{1}".format(self.field, self.direction) + return f"{self.field}:{self.direction}" diff --git a/test/test_dataalert.py b/test/test_dataalert.py index d9e00a9db..6f6f1683c 100644 --- a/test/test_dataalert.py +++ b/test/test_dataalert.py @@ -108,5 +108,5 @@ def test_delete_user_from_alert(self) -> None: alert_id = "5ea59b45-e497-5673-8809-bfe213236f75" user_id = "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" with requests_mock.mock() as m: - m.delete(self.baseurl + "/{0}/users/{1}".format(alert_id, user_id), status_code=204) + m.delete(self.baseurl + f"/{alert_id}/users/{user_id}", status_code=204) self.server.data_alerts.delete_user_from_alert(alert_id, user_id) diff --git a/test/test_datasource.py b/test/test_datasource.py index 624eb93e1..45d9ba9c9 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -75,7 +75,7 @@ def test_get(self) -> None: self.assertEqual("Sample datasource", all_datasources[1].name) self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", all_datasources[1].project_id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_datasources[1].owner_id) - self.assertEqual(set(["world", "indicators", "sample"]), all_datasources[1].tags) + self.assertEqual({"world", "indicators", "sample"}, all_datasources[1].tags) self.assertEqual("https://page.com", all_datasources[1].webpage_url) self.assertTrue(all_datasources[1].encrypt_extracts) self.assertFalse(all_datasources[1].has_extracts) @@ -110,7 +110,7 @@ def test_get_by_id(self) -> None: self.assertEqual("Sample datasource", single_datasource.name) self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", single_datasource.project_id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_datasource.owner_id) - self.assertEqual(set(["world", "indicators", "sample"]), single_datasource.tags) + self.assertEqual({"world", "indicators", "sample"}, single_datasource.tags) self.assertEqual(TSC.DatasourceItem.AskDataEnablement.SiteDefault, single_datasource.ask_data_enablement) def test_update(self) -> None: @@ -488,7 +488,7 @@ def test_download_object(self) -> None: def test_download_sanitizes_name(self) -> None: filename = "Name,With,Commas.tds" - disposition = 'name="tableau_workbook"; filename="{}"'.format(filename) + disposition = f'name="tableau_workbook"; filename="{filename}"' with requests_mock.mock() as m: m.get( self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", @@ -659,7 +659,7 @@ def test_revisions(self) -> None: response_xml = read_xml_asset(REVISION_XML) with requests_mock.mock() as m: - m.get("{0}/{1}/revisions".format(self.baseurl, datasource.id), text=response_xml) + m.get(f"{self.baseurl}/{datasource.id}/revisions", text=response_xml) self.server.datasources.populate_revisions(datasource) revisions = datasource.revisions @@ -687,7 +687,7 @@ def test_delete_revision(self) -> None: datasource._id = "06b944d2-959d-4604-9305-12323c95e70e" with requests_mock.mock() as m: - m.delete("{0}/{1}/revisions/3".format(self.baseurl, datasource.id)) + m.delete(f"{self.baseurl}/{datasource.id}/revisions/3") self.server.datasources.delete_revision(datasource.id, "3") def test_download_revision(self) -> None: diff --git a/test/test_endpoint.py b/test/test_endpoint.py index 8635af978..ff1ef0f72 100644 --- a/test/test_endpoint.py +++ b/test/test_endpoint.py @@ -54,7 +54,7 @@ def test_get_request_stream(self) -> None: self.assertFalse(response._content_consumed) def test_binary_log_truncated(self): - class FakeResponse(object): + class FakeResponse: headers = {"Content-Type": "application/octet-stream"} content = b"\x1337" * 1000 status_code = 200 diff --git a/test/test_favorites.py b/test/test_favorites.py index 6f0be3b3c..87332d70f 100644 --- a/test/test_favorites.py +++ b/test/test_favorites.py @@ -28,7 +28,7 @@ def setUp(self): def test_get(self) -> None: response_xml = read_xml_asset(GET_FAVORITES_XML) with requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) + m.get(f"{self.baseurl}/{self.user.id}", text=response_xml) self.server.favorites.get(self.user) self.assertIsNotNone(self.user._favorites) self.assertEqual(len(self.user.favorites["workbooks"]), 1) @@ -54,7 +54,7 @@ def test_add_favorite_workbook(self) -> None: workbook._id = "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00" workbook.name = "Superstore" with requests_mock.mock() as m: - m.put("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) + m.put(f"{self.baseurl}/{self.user.id}", text=response_xml) self.server.favorites.add_favorite_workbook(self.user, workbook) def test_add_favorite_view(self) -> None: @@ -63,7 +63,7 @@ def test_add_favorite_view(self) -> None: view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" view._name = "ENDANGERED SAFARI" with requests_mock.mock() as m: - m.put("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) + m.put(f"{self.baseurl}/{self.user.id}", text=response_xml) self.server.favorites.add_favorite_view(self.user, view) def test_add_favorite_datasource(self) -> None: @@ -72,7 +72,7 @@ def test_add_favorite_datasource(self) -> None: datasource._id = "e76a1461-3b1d-4588-bf1b-17551a879ad9" datasource.name = "SampleDS" with requests_mock.mock() as m: - m.put("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) + m.put(f"{self.baseurl}/{self.user.id}", text=response_xml) self.server.favorites.add_favorite_datasource(self.user, datasource) def test_add_favorite_project(self) -> None: @@ -82,7 +82,7 @@ def test_add_favorite_project(self) -> None: project = TSC.ProjectItem("Tableau") project._id = "1d0304cd-3796-429f-b815-7258370b9b74" with requests_mock.mock() as m: - m.put("{0}/{1}".format(baseurl, self.user.id), text=response_xml) + m.put(f"{baseurl}/{self.user.id}", text=response_xml) self.server.favorites.add_favorite_project(self.user, project) def test_delete_favorite_workbook(self) -> None: @@ -90,7 +90,7 @@ def test_delete_favorite_workbook(self) -> None: workbook._id = "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00" workbook.name = "Superstore" with requests_mock.mock() as m: - m.delete("{0}/{1}/workbooks/{2}".format(self.baseurl, self.user.id, workbook.id)) + m.delete(f"{self.baseurl}/{self.user.id}/workbooks/{workbook.id}") self.server.favorites.delete_favorite_workbook(self.user, workbook) def test_delete_favorite_view(self) -> None: @@ -98,7 +98,7 @@ def test_delete_favorite_view(self) -> None: view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" view._name = "ENDANGERED SAFARI" with requests_mock.mock() as m: - m.delete("{0}/{1}/views/{2}".format(self.baseurl, self.user.id, view.id)) + m.delete(f"{self.baseurl}/{self.user.id}/views/{view.id}") self.server.favorites.delete_favorite_view(self.user, view) def test_delete_favorite_datasource(self) -> None: @@ -106,7 +106,7 @@ def test_delete_favorite_datasource(self) -> None: datasource._id = "e76a1461-3b1d-4588-bf1b-17551a879ad9" datasource.name = "SampleDS" with requests_mock.mock() as m: - m.delete("{0}/{1}/datasources/{2}".format(self.baseurl, self.user.id, datasource.id)) + m.delete(f"{self.baseurl}/{self.user.id}/datasources/{datasource.id}") self.server.favorites.delete_favorite_datasource(self.user, datasource) def test_delete_favorite_project(self) -> None: @@ -115,5 +115,5 @@ def test_delete_favorite_project(self) -> None: project = TSC.ProjectItem("Tableau") project._id = "1d0304cd-3796-429f-b815-7258370b9b74" with requests_mock.mock() as m: - m.delete("{0}/{1}/projects/{2}".format(baseurl, self.user.id, project.id)) + m.delete(f"{baseurl}/{self.user.id}/projects/{project.id}") self.server.favorites.delete_favorite_project(self.user, project) diff --git a/test/test_filesys_helpers.py b/test/test_filesys_helpers.py index 4c8fb0f9f..0f3234d5d 100644 --- a/test/test_filesys_helpers.py +++ b/test/test_filesys_helpers.py @@ -37,7 +37,7 @@ def test_get_file_type_identifies_a_zip_file(self): with BytesIO() as file_object: with ZipFile(file_object, "w") as zf: with BytesIO() as stream: - stream.write("This is a zip file".encode()) + stream.write(b"This is a zip file") zf.writestr("dummy_file", stream.getbuffer()) file_object.seek(0) file_type = get_file_type(file_object) diff --git a/test/test_fileuploads.py b/test/test_fileuploads.py index 50a5ef48b..9567bc3ad 100644 --- a/test/test_fileuploads.py +++ b/test/test_fileuploads.py @@ -33,7 +33,7 @@ def setUp(self): self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - self.baseurl = "{}/sites/{}/fileUploads".format(self.server.baseurl, self.server.site_id) + self.baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/fileUploads" def test_read_chunks_file_path(self): file_path = asset("SampleWB.twbx") @@ -57,7 +57,7 @@ def test_upload_chunks_file_path(self): append_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=initialize_response_xml) - m.put("{}/{}".format(self.baseurl, upload_id), text=append_response_xml) + m.put(f"{self.baseurl}/{upload_id}", text=append_response_xml) actual = self.server.fileuploads.upload(file_path) self.assertEqual(upload_id, actual) @@ -72,7 +72,7 @@ def test_upload_chunks_file_object(self): append_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=initialize_response_xml) - m.put("{}/{}".format(self.baseurl, upload_id), text=append_response_xml) + m.put(f"{self.baseurl}/{upload_id}", text=append_response_xml) actual = self.server.fileuploads.upload(file_content) self.assertEqual(upload_id, actual) diff --git a/test/test_flowruns.py b/test/test_flowruns.py index 864c0d3cd..e1ddd5541 100644 --- a/test/test_flowruns.py +++ b/test/test_flowruns.py @@ -75,7 +75,7 @@ def test_wait_for_job_finished(self) -> None: response_xml = read_xml_asset(GET_BY_ID_XML) flow_run_id = "cc2e652d-4a9b-4476-8c93-b238c45db968" with mocked_time(), requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, flow_run_id), text=response_xml) + m.get(f"{self.baseurl}/{flow_run_id}", text=response_xml) flow_run = self.server.flow_runs.wait_for_job(flow_run_id) self.assertEqual(flow_run_id, flow_run.id) @@ -86,7 +86,7 @@ def test_wait_for_job_failed(self) -> None: response_xml = read_xml_asset(GET_BY_ID_FAILED_XML) flow_run_id = "c2b35d5a-e130-471a-aec8-7bc5435fe0e7" with mocked_time(), requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, flow_run_id), text=response_xml) + m.get(f"{self.baseurl}/{flow_run_id}", text=response_xml) with self.assertRaises(FlowRunFailedException): self.server.flow_runs.wait_for_job(flow_run_id) @@ -95,6 +95,6 @@ def test_wait_for_job_timeout(self) -> None: response_xml = read_xml_asset(GET_BY_ID_INPROGRESS_XML) flow_run_id = "71afc22c-9c06-40be-8d0f-4c4166d29e6c" with mocked_time(), requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, flow_run_id), text=response_xml) + m.get(f"{self.baseurl}/{flow_run_id}", text=response_xml) with self.assertRaises(TimeoutError): self.server.flow_runs.wait_for_job(flow_run_id, timeout=30) diff --git a/test/test_flowtask.py b/test/test_flowtask.py index 034066e64..2d9f7c7bd 100644 --- a/test/test_flowtask.py +++ b/test/test_flowtask.py @@ -40,7 +40,7 @@ def test_create_flow_task(self): 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) + m.post(f"{self.baseurl}", text=response_xml) create_response_content = self.server.flow_tasks.create(task).decode("utf-8") self.assertTrue("schedule_id" in create_response_content) diff --git a/test/test_group.py b/test/test_group.py index fc9c75a6d..41b5992be 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -1,4 +1,3 @@ -# encoding=utf-8 from pathlib import Path import unittest import os diff --git a/test/test_job.py b/test/test_job.py index d86397086..20b238764 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -51,7 +51,7 @@ def test_get_by_id(self) -> None: response_xml = read_xml_asset(GET_BY_ID_XML) job_id = "2eef4225-aa0c-41c4-8662-a76d89ed7336" with requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) + m.get(f"{self.baseurl}/{job_id}", text=response_xml) job = self.server.jobs.get_by_id(job_id) updated_at = datetime(2020, 5, 13, 20, 25, 18, tzinfo=utc) @@ -81,7 +81,7 @@ def test_wait_for_job_finished(self) -> None: response_xml = read_xml_asset(GET_BY_ID_XML) job_id = "2eef4225-aa0c-41c4-8662-a76d89ed7336" with mocked_time(), requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) + m.get(f"{self.baseurl}/{job_id}", text=response_xml) job = self.server.jobs.wait_for_job(job_id) self.assertEqual(job_id, job.id) @@ -92,7 +92,7 @@ def test_wait_for_job_failed(self) -> None: response_xml = read_xml_asset(GET_BY_ID_FAILED_XML) job_id = "77d5e57a-2517-479f-9a3c-a32025f2b64d" with mocked_time(), requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) + m.get(f"{self.baseurl}/{job_id}", text=response_xml) with self.assertRaises(JobFailedException): self.server.jobs.wait_for_job(job_id) @@ -101,7 +101,7 @@ def test_wait_for_job_timeout(self) -> None: response_xml = read_xml_asset(GET_BY_ID_INPROGRESS_XML) job_id = "77d5e57a-2517-479f-9a3c-a32025f2b64d" with mocked_time(), requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) + m.get(f"{self.baseurl}/{job_id}", text=response_xml) with self.assertRaises(TimeoutError): self.server.jobs.wait_for_job(job_id, timeout=30) diff --git a/test/test_project.py b/test/test_project.py index e05785f86..430db84b2 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -241,9 +241,9 @@ def test_delete_permission(self) -> None: rules = TSC.PermissionsRule(grantee=GroupItem.as_reference(single_group._id), capabilities=capabilities) - endpoint = "{}/permissions/groups/{}".format(single_project._id, single_group._id) - m.delete("{}/{}/Read/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/Write/Allow".format(self.baseurl, endpoint), status_code=204) + endpoint = f"{single_project._id}/permissions/groups/{single_group._id}" + m.delete(f"{self.baseurl}/{endpoint}/Read/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/Write/Allow", status_code=204) self.server.projects.delete_permission(item=single_project, rules=rules) def test_delete_workbook_default_permission(self) -> None: @@ -287,19 +287,19 @@ def test_delete_workbook_default_permission(self) -> None: rules = TSC.PermissionsRule(grantee=GroupItem.as_reference(single_group._id), capabilities=capabilities) - endpoint = "{}/default-permissions/workbooks/groups/{}".format(single_project._id, single_group._id) - m.delete("{}/{}/Read/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ExportImage/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ExportData/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ViewComments/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/AddComment/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/Filter/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ViewUnderlyingData/Deny".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ShareView/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/WebAuthoring/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/Write/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ExportXml/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ChangeHierarchy/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/Delete/Deny".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ChangePermissions/Allow".format(self.baseurl, endpoint), status_code=204) + endpoint = f"{single_project._id}/default-permissions/workbooks/groups/{single_group._id}" + m.delete(f"{self.baseurl}/{endpoint}/Read/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ExportImage/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ExportData/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ViewComments/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/AddComment/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/Filter/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ViewUnderlyingData/Deny", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ShareView/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/WebAuthoring/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/Write/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ExportXml/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ChangeHierarchy/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/Delete/Deny", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ChangePermissions/Allow", status_code=204) self.server.projects.delete_workbook_default_permissions(item=single_project, rule=rules) diff --git a/test/test_regression_tests.py b/test/test_regression_tests.py index 772704f69..62e301591 100644 --- a/test/test_regression_tests.py +++ b/test/test_regression_tests.py @@ -1,9 +1,5 @@ import unittest - -try: - from unittest import mock -except ImportError: - import mock # type: ignore[no-redef] +from unittest import mock import tableauserverclient.server.request_factory as factory from tableauserverclient.helpers.strings import redact_xml diff --git a/test/test_request_option.py b/test/test_request_option.py index e48f8510a..9ca9779ad 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -31,7 +31,7 @@ def setUp(self) -> None: self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - self.baseurl = "{0}/{1}".format(self.server.sites.baseurl, self.server._site_id) + self.baseurl = f"{self.server.sites.baseurl}/{self.server._site_id}" def test_pagination(self) -> None: with open(PAGINATION_XML, "rb") as f: @@ -112,9 +112,9 @@ def test_filter_tags_in(self) -> None: matching_workbooks, pagination_item = self.server.workbooks.get(req_option) self.assertEqual(3, pagination_item.total_available) - self.assertEqual(set(["weather"]), matching_workbooks[0].tags) - self.assertEqual(set(["safari"]), matching_workbooks[1].tags) - self.assertEqual(set(["sample"]), matching_workbooks[2].tags) + self.assertEqual({"weather"}, matching_workbooks[0].tags) + self.assertEqual({"safari"}, matching_workbooks[1].tags) + self.assertEqual({"sample"}, matching_workbooks[2].tags) # check if filtered projects with spaces & special characters # get correctly returned @@ -148,9 +148,9 @@ def test_filter_tags_in_shorthand(self) -> None: matching_workbooks = self.server.workbooks.filter(tags__in=["sample", "safari", "weather"]) self.assertEqual(3, matching_workbooks.total_available) - self.assertEqual(set(["weather"]), matching_workbooks[0].tags) - self.assertEqual(set(["safari"]), matching_workbooks[1].tags) - self.assertEqual(set(["sample"]), matching_workbooks[2].tags) + self.assertEqual({"weather"}, matching_workbooks[0].tags) + self.assertEqual({"safari"}, matching_workbooks[1].tags) + self.assertEqual({"sample"}, matching_workbooks[2].tags) def test_invalid_shorthand_option(self) -> None: with self.assertRaises(ValueError): diff --git a/test/test_schedule.py b/test/test_schedule.py index 0377295d7..1d329f86e 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -106,7 +106,7 @@ def test_get_by_id(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -120,7 +120,7 @@ def test_get_hourly_by_id(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -135,7 +135,7 @@ def test_get_daily_by_id(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -150,7 +150,7 @@ def test_get_monthly_by_id(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -347,7 +347,7 @@ def test_update_after_get(self) -> None: def test_add_workbook(self) -> None: self.server.version = "2.8" - baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) + baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" with open(WORKBOOK_GET_BY_ID_XML, "rb") as f: workbook_response = f.read().decode("utf-8") @@ -362,7 +362,7 @@ def test_add_workbook(self) -> None: def test_add_workbook_with_warnings(self) -> None: self.server.version = "2.8" - baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) + baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" with open(WORKBOOK_GET_BY_ID_XML, "rb") as f: workbook_response = f.read().decode("utf-8") @@ -378,7 +378,7 @@ def test_add_workbook_with_warnings(self) -> None: def test_add_datasource(self) -> None: self.server.version = "2.8" - baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) + baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" with open(DATASOURCE_GET_BY_ID_XML, "rb") as f: datasource_response = f.read().decode("utf-8") @@ -393,7 +393,7 @@ def test_add_datasource(self) -> None: def test_add_flow(self) -> None: self.server.version = "3.3" - baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) + baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" with open(FLOW_GET_BY_ID_XML, "rb") as f: flow_response = f.read().decode("utf-8") diff --git a/test/test_site_model.py b/test/test_site_model.py index f62eb66f0..60ad9c5e5 100644 --- a/test/test_site_model.py +++ b/test/test_site_model.py @@ -1,5 +1,3 @@ -# coding=utf-8 - import unittest import tableauserverclient as TSC diff --git a/test/test_tagging.py b/test/test_tagging.py index 0184af415..23dffebfb 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -1,6 +1,6 @@ from contextlib import ExitStack import re -from typing import Iterable +from collections.abc import Iterable import uuid from xml.etree import ElementTree as ET @@ -172,7 +172,7 @@ def test_update_tags(get_server, endpoint_type, item, tags) -> None: if isinstance(item, str): stack.enter_context(pytest.raises((ValueError, NotImplementedError))) elif hasattr(item, "_initial_tags"): - initial_tags = set(["x", "y", "z"]) + initial_tags = {"x", "y", "z"} item._initial_tags = initial_tags add_tags_xml = add_tag_xml_response_factory(tags - initial_tags) delete_tags_xml = add_tag_xml_response_factory(initial_tags - tags) diff --git a/test/test_task.py b/test/test_task.py index 53da7c160..2d724b879 100644 --- a/test/test_task.py +++ b/test/test_task.py @@ -119,7 +119,7 @@ def test_get_materializeviews_tasks(self): with open(GET_XML_DATAACCELERATION_TASK, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get("{}/{}".format(self.server.tasks.baseurl, TaskItem.Type.DataAcceleration), text=response_xml) + m.get(f"{self.server.tasks.baseurl}/{TaskItem.Type.DataAcceleration}", text=response_xml) all_tasks, pagination_item = self.server.tasks.get(task_type=TaskItem.Type.DataAcceleration) task = all_tasks[0] @@ -145,7 +145,7 @@ def test_get_by_id(self): response_xml = f.read().decode("utf-8") task_id = "f84901ac-72ad-4f9b-a87e-7a3500402ad6" with requests_mock.mock() as m: - m.get("{}/{}".format(self.baseurl, task_id), text=response_xml) + m.get(f"{self.baseurl}/{task_id}", text=response_xml) task = self.server.tasks.get_by_id(task_id) self.assertEqual("c7a9327e-1cda-4504-b026-ddb43b976d1d", task.target.id) @@ -159,7 +159,7 @@ def test_run_now(self): with open(GET_XML_RUN_NOW_RESPONSE, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post("{}/{}/runNow".format(self.baseurl, task_id), text=response_xml) + m.post(f"{self.baseurl}/{task_id}/runNow", text=response_xml) job_response_content = self.server.tasks.run(task).decode("utf-8") self.assertTrue("7b6b59a8-ac3c-4d1d-2e9e-0b5b4ba8a7b6" in job_response_content) @@ -181,7 +181,7 @@ def test_create_extract_task(self): 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) + m.post(f"{self.baseurl}", text=response_xml) create_response_content = self.server.tasks.create(task).decode("utf-8") self.assertTrue("task_id" in create_response_content) diff --git a/test/test_user.py b/test/test_user.py index 1f5eba57f..a46624845 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -1,8 +1,5 @@ -import io import os import unittest -from typing import List -from unittest.mock import MagicMock import requests_mock @@ -163,7 +160,7 @@ def test_populate_workbooks(self) -> None: self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", workbook_list[0].project_id) self.assertEqual("default", workbook_list[0].project_name) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", workbook_list[0].owner_id) - self.assertEqual(set(["Safari", "Sample"]), workbook_list[0].tags) + self.assertEqual({"Safari", "Sample"}, workbook_list[0].tags) def test_populate_workbooks_missing_id(self) -> None: single_user = TSC.UserItem("test", "Interactor") @@ -176,7 +173,7 @@ def test_populate_favorites(self) -> None: with open(GET_FAVORITES_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get("{0}/{1}".format(baseurl, single_user.id), text=response_xml) + m.get(f"{baseurl}/{single_user.id}", text=response_xml) self.server.users.populate_favorites(single_user) self.assertIsNotNone(single_user._favorites) self.assertEqual(len(single_user.favorites["workbooks"]), 1) diff --git a/test/test_user_model.py b/test/test_user_model.py index d0997b9ff..a8a2c51cb 100644 --- a/test/test_user_model.py +++ b/test/test_user_model.py @@ -1,7 +1,6 @@ import logging import unittest from unittest.mock import * -from typing import List import io import pytest @@ -107,7 +106,7 @@ def test_validate_user_detail_standard(self): TSC.UserItem.CSVImport.create_user_from_line(test_line) # for file handling - def _mock_file_content(self, content: List[str]) -> io.TextIOWrapper: + def _mock_file_content(self, content: list[str]) -> io.TextIOWrapper: # the empty string represents EOF # the tests run through the file twice, first to validate then to fetch mock = MagicMock(io.TextIOWrapper) @@ -119,10 +118,10 @@ def _mock_file_content(self, content: List[str]) -> io.TextIOWrapper: def test_validate_import_file(self): test_data = self._mock_file_content(UserDataTest.valid_import_content) valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, UserDataTest.logger) - assert valid == 2, "Expected two lines to be parsed, got {}".format(valid) - assert invalid == [], "Expected no failures, got {}".format(invalid) + assert valid == 2, f"Expected two lines to be parsed, got {valid}" + assert invalid == [], f"Expected no failures, got {invalid}" def test_validate_usernames_file(self): test_data = self._mock_file_content(UserDataTest.usernames) valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, UserDataTest.logger) - assert valid == 5, "Exactly 5 of the lines were valid, counted {}".format(valid + invalid) + assert valid == 5, f"Exactly 5 of the lines were valid, counted {valid + invalid}" diff --git a/test/test_view.py b/test/test_view.py index 1c667a4c3..a89a6d235 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -49,7 +49,7 @@ def test_get(self) -> None: self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", all_views[0].workbook_id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_views[0].owner_id) self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", all_views[0].project_id) - self.assertEqual(set(["tag1", "tag2"]), all_views[0].tags) + self.assertEqual({"tag1", "tag2"}, all_views[0].tags) self.assertIsNone(all_views[0].created_at) self.assertIsNone(all_views[0].updated_at) self.assertIsNone(all_views[0].sheet_type) @@ -77,7 +77,7 @@ def test_get_by_id(self) -> None: 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({"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) @@ -95,7 +95,7 @@ def test_get_by_id_usage(self) -> None: 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({"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) diff --git a/test/test_view_acceleration.py b/test/test_view_acceleration.py index 6f94f0c10..766831b0a 100644 --- a/test/test_view_acceleration.py +++ b/test/test_view_acceleration.py @@ -42,7 +42,7 @@ def test_get_by_id(self) -> None: 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({"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) diff --git a/test/test_workbook.py b/test/test_workbook.py index 950118dc0..1a6b3192f 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -83,7 +83,7 @@ def test_get(self) -> None: self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", all_workbooks[1].project_id) self.assertEqual("default", all_workbooks[1].project_name) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_workbooks[1].owner_id) - self.assertEqual(set(["Safari", "Sample"]), all_workbooks[1].tags) + self.assertEqual({"Safari", "Sample"}, all_workbooks[1].tags) def test_get_ignore_invalid_date(self) -> None: with open(GET_INVALID_DATE_XML, "rb") as f: @@ -127,7 +127,7 @@ def test_get_by_id(self) -> None: 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({"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) @@ -152,7 +152,7 @@ def test_get_by_id_personal(self) -> None: self.assertTrue(single_workbook.project_id) self.assertIsNone(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({"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) @@ -277,7 +277,7 @@ def test_download_object(self) -> None: def test_download_sanitizes_name(self) -> None: filename = "Name,With,Commas.twbx" - disposition = 'name="tableau_workbook"; filename="{}"'.format(filename) + disposition = f'name="tableau_workbook"; filename="{filename}"' with requests_mock.mock() as m: m.get( self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", @@ -817,7 +817,7 @@ def test_revisions(self) -> None: with open(REVISION_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get("{0}/{1}/revisions".format(self.baseurl, workbook.id), text=response_xml) + m.get(f"{self.baseurl}/{workbook.id}/revisions", text=response_xml) self.server.workbooks.populate_revisions(workbook) revisions = workbook.revisions @@ -846,7 +846,7 @@ def test_delete_revision(self) -> None: workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" with requests_mock.mock() as m: - m.delete("{0}/{1}/revisions/3".format(self.baseurl, workbook.id)) + m.delete(f"{self.baseurl}/{workbook.id}/revisions/3") self.server.workbooks.delete_revision(workbook.id, "3") def test_download_revision(self) -> None: diff --git a/versioneer.py b/versioneer.py index 86c240e13..cce899f58 100644 --- a/versioneer.py +++ b/versioneer.py @@ -276,7 +276,6 @@ """ -from __future__ import print_function try: import configparser @@ -328,7 +327,7 @@ def get_root(): me_dir = os.path.normcase(os.path.splitext(me)[0]) vsr_dir = os.path.normcase(os.path.splitext(versioneer_py)[0]) if me_dir != vsr_dir: - print("Warning: build in %s is using versioneer.py from %s" % (os.path.dirname(me), versioneer_py)) + print(f"Warning: build in {os.path.dirname(me)} is using versioneer.py from {versioneer_py}") except NameError: pass return root @@ -342,7 +341,7 @@ def get_config_from_root(root): # the top of versioneer.py for instructions on writing your setup.cfg . setup_cfg = os.path.join(root, "setup.cfg") parser = configparser.SafeConfigParser() - with open(setup_cfg, "r") as f: + with open(setup_cfg) as f: parser.readfp(f) VCS = parser.get("versioneer", "VCS") # mandatory @@ -398,7 +397,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= [c] + args, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=(subprocess.PIPE if hide_stderr else None) ) break - except EnvironmentError: + except OSError: e = sys.exc_info()[1] if e.errno == errno.ENOENT: continue @@ -408,7 +407,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= return None, None else: if verbose: - print("unable to find command, tried %s" % (commands,)) + print(f"unable to find command, tried {commands}" return None, None stdout = p.communicate()[0].strip() if sys.version_info[0] >= 3: @@ -423,7 +422,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= LONG_VERSION_PY[ "git" -] = ''' +] = r''' # This file helps to compute a version number in source trees obtained from # git-archive tarball (such as those provided by githubs download-from-tag # feature). Distribution tarballs (built by setup.py sdist) and build @@ -955,7 +954,7 @@ def git_get_keywords(versionfile_abs): # _version.py. keywords = {} try: - f = open(versionfile_abs, "r") + f = open(versionfile_abs) for line in f.readlines(): if line.strip().startswith("git_refnames ="): mo = re.search(r'=\s*"(.*)"', line) @@ -970,7 +969,7 @@ def git_get_keywords(versionfile_abs): if mo: keywords["date"] = mo.group(1) f.close() - except EnvironmentError: + except OSError: pass return keywords @@ -994,11 +993,11 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): if verbose: print("keywords are unexpanded, not using") raise NotThisMethod("unexpanded keywords, not a git-archive tarball") - refs = set([r.strip() for r in refnames.strip("()").split(",")]) + refs = {r.strip() for r in refnames.strip("()").split(",")} # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. TAG = "tag: " - tags = set([r[len(TAG) :] for r in refs if r.startswith(TAG)]) + tags = {r[len(TAG) :] for r in refs if r.startswith(TAG)} if not tags: # Either we're using git < 1.8.3, or there really are no tags. We use # a heuristic: assume all version tags have a digit. The old git %d @@ -1007,7 +1006,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # between branches and tags. By ignoring refnames without digits, we # filter out many common branch names like "release" and # "stabilization", as well as "HEAD" and "master". - tags = set([r for r in refs if re.search(r"\d", r)]) + tags = {r for r in refs if re.search(r"\d", r)} if verbose: print("discarding '%s', no digits" % ",".join(refs - tags)) if verbose: @@ -1100,7 +1099,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if verbose: fmt = "tag '%s' doesn't start with prefix '%s'" print(fmt % (full_tag, tag_prefix)) - pieces["error"] = "tag '%s' doesn't start with prefix '%s'" % (full_tag, tag_prefix) + pieces["error"] = f"tag '{full_tag}' doesn't start with prefix '{tag_prefix}'" return pieces pieces["closest-tag"] = full_tag[len(tag_prefix) :] @@ -1145,13 +1144,13 @@ def do_vcs_install(manifest_in, versionfile_source, ipy): files.append(versioneer_file) present = False try: - f = open(".gitattributes", "r") + f = open(".gitattributes") for line in f.readlines(): if line.strip().startswith(versionfile_source): if "export-subst" in line.strip().split()[1:]: present = True f.close() - except EnvironmentError: + except OSError: pass if not present: f = open(".gitattributes", "a+") @@ -1185,7 +1184,7 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): root = os.path.dirname(root) # up a level if verbose: - print("Tried directories %s but none started with prefix %s" % (str(rootdirs), parentdir_prefix)) + print(f"Tried directories {rootdirs!s} but none started with prefix {parentdir_prefix}") raise NotThisMethod("rootdir doesn't start with parentdir_prefix") @@ -1212,7 +1211,7 @@ def versions_from_file(filename): try: with open(filename) as f: contents = f.read() - except EnvironmentError: + except OSError: raise NotThisMethod("unable to read _version.py") mo = re.search(r"version_json = '''\n(.*)''' # END VERSION_JSON", contents, re.M | re.S) if not mo: @@ -1229,7 +1228,7 @@ def write_to_version_file(filename, versions): with open(filename, "w") as f: f.write(SHORT_VERSION_PY % contents) - print("set %s to '%s'" % (filename, versions["version"])) + print(f"set {filename} to '{versions['version']}'") def plus_or_dot(pieces): @@ -1452,7 +1451,7 @@ def get_versions(verbose=False): try: ver = versions_from_file(versionfile_abs) if verbose: - print("got version from file %s %s" % (versionfile_abs, ver)) + print(f"got version from file {versionfile_abs} {ver}") return ver except NotThisMethod: pass @@ -1723,7 +1722,7 @@ def do_setup(): root = get_root() try: cfg = get_config_from_root(root) - except (EnvironmentError, configparser.NoSectionError, configparser.NoOptionError) as e: + except (OSError, configparser.NoSectionError, configparser.NoOptionError) as e: if isinstance(e, (EnvironmentError, configparser.NoSectionError)): print("Adding sample versioneer config to setup.cfg", file=sys.stderr) with open(os.path.join(root, "setup.cfg"), "a") as f: @@ -1748,9 +1747,9 @@ def do_setup(): ipy = os.path.join(os.path.dirname(cfg.versionfile_source), "__init__.py") if os.path.exists(ipy): try: - with open(ipy, "r") as f: + with open(ipy) as f: old = f.read() - except EnvironmentError: + except OSError: old = "" if INIT_PY_SNIPPET not in old: print(" appending to %s" % ipy) @@ -1769,12 +1768,12 @@ def do_setup(): manifest_in = os.path.join(root, "MANIFEST.in") simple_includes = set() try: - with open(manifest_in, "r") as f: + with open(manifest_in) as f: for line in f: if line.startswith("include "): for include in line.split()[1:]: simple_includes.add(include) - except EnvironmentError: + except OSError: pass # That doesn't cover everything MANIFEST.in can do # (http://docs.python.org/2/distutils/sourcedist.html#commands), so @@ -1805,7 +1804,7 @@ def scan_setup_py(): found = set() setters = False errors = 0 - with open("setup.py", "r") as f: + with open("setup.py") as f: for line in f.readlines(): if "import versioneer" in line: found.add("import") From 2a7fb2bf5e63411e1aac1b4cea0a93c6171740eb Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 20 Sep 2024 01:36:53 -0500 Subject: [PATCH 234/296] chore(typing): include samples in type checks (#1455) * chore(typing): include samples in type checks Including the sample scripts in type checking will allow more thorough testing to validate the samples work as expected, as well as more testing around how a library consumer may use the library. --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Co-authored-by: Jac --- pyproject.toml | 2 +- samples/explore_favorites.py | 6 +++--- samples/list.py | 3 +++ tableauserverclient/models/favorites_item.py | 5 +++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cc3bf8fab..c3cb67eda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ disable_error_code = [ # 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"] +files = ["tableauserverclient", "test", "samples"] show_error_codes = true ignore_missing_imports = true # defusedxml library has no types no_implicit_reexport = true diff --git a/samples/explore_favorites.py b/samples/explore_favorites.py index 364e078cc..f199522ed 100644 --- a/samples/explore_favorites.py +++ b/samples/explore_favorites.py @@ -3,7 +3,7 @@ import argparse import logging import tableauserverclient as TSC -from tableauserverclient import Resource +from tableauserverclient.models import Resource def main(): @@ -46,8 +46,8 @@ def main(): # get list of workbooks all_workbook_items, pagination_item = server.workbooks.get() if all_workbook_items is not None and len(all_workbook_items) > 0: - my_workbook: TSC.WorkbookItem = all_workbook_items[0] - server.favorites.add_favorite(server, user, Resource.Workbook.name(), all_workbook_items[0]) + my_workbook = all_workbook_items[0] + server.favorites.add_favorite(user, Resource.Workbook, all_workbook_items[0]) print( "Workbook added to favorites. Workbook Name: {}, Workbook ID: {}".format( my_workbook.name, my_workbook.id diff --git a/samples/list.py b/samples/list.py index 11e664695..2675a2954 100644 --- a/samples/list.py +++ b/samples/list.py @@ -48,6 +48,9 @@ def main(): "webhooks": server.webhooks, "workbook": server.workbooks, }.get(args.resource_type) + if endpoint is None: + print("Resource type not found.") + sys.exit(1) options = TSC.RequestOptions() options.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Direction.Desc)) diff --git a/tableauserverclient/models/favorites_item.py b/tableauserverclient/models/favorites_item.py index f157283cb..4fea280f7 100644 --- a/tableauserverclient/models/favorites_item.py +++ b/tableauserverclient/models/favorites_item.py @@ -1,8 +1,9 @@ import logging +from typing import Union from defusedxml.ElementTree import fromstring -from tableauserverclient.models.tableau_types import TableauItem +from tableauserverclient.models.tableau_types import TableauItem from tableauserverclient.models.datasource_item import DatasourceItem from tableauserverclient.models.flow_item import FlowItem from tableauserverclient.models.project_item import ProjectItem @@ -20,7 +21,7 @@ class FavoriteItem: @classmethod - def from_response(cls, xml: str, namespace: dict) -> FavoriteType: + def from_response(cls, xml: Union[str, bytes], namespace: dict) -> FavoriteType: favorites: FavoriteType = { "datasources": [], "flows": [], From 6ec632e328a744be4be733b1a0c697f74bf3a3c1 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 20 Sep 2024 15:03:13 -0500 Subject: [PATCH 235/296] fix: queryset support for flowruns (#1460) * fix: queryset support for flowruns FlowRun's get endpoint does not return a PaginationItem. This provides a tweak to QuerySet to provide a workaround so all items matching whatever filters are supplied. It also corrects the return types of flowruns.get and fixes the XML test asset to reflect what is really returned by the server. * fix: set unknown size to sys.maxsize Users may length check a QuerySet as part of a normal workflow. A len of 0 would be misleading, indicating to the user that there are no matches for the endpoint and/or filters they supplied. __len__ must return a non-negative int. Sentinel values such as -1 or None do not work. This only leaves maxsize as the possible flag. * fix: docstring on QuerySet * refactor(test): extract error factory to _utils * chore(typing): flowruns.cancel can also accept a FlowRunItem * style: black --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- .../server/endpoint/flow_runs_endpoint.py | 14 ++-- tableauserverclient/server/query.py | 64 ++++++++++++++++--- test/_utils.py | 14 ++++ test/assets/flow_runs_get.xml | 3 +- test/test_flowruns.py | 17 ++++- 5 files changed, 92 insertions(+), 20 deletions(-) diff --git a/tableauserverclient/server/endpoint/flow_runs_endpoint.py b/tableauserverclient/server/endpoint/flow_runs_endpoint.py index 3d09ad569..2c3bb84bc 100644 --- a/tableauserverclient/server/endpoint/flow_runs_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_runs_endpoint.py @@ -1,9 +1,9 @@ import logging -from typing import Optional, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING, Union from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api from tableauserverclient.server.endpoint.exceptions import FlowRunFailedException, FlowRunCancelledException -from tableauserverclient.models import FlowRunItem, PaginationItem +from tableauserverclient.models import FlowRunItem from tableauserverclient.exponential_backoff import ExponentialBackoffTimer from tableauserverclient.helpers.logging import logger @@ -25,13 +25,15 @@ def baseurl(self) -> str: # Get all flows @api(version="3.10") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[FlowRunItem], PaginationItem]: + # QuerysetEndpoint expects a PaginationItem to be returned, but FlowRuns + # does not return a PaginationItem. Suppressing the mypy error because the + # changes to the QuerySet class should permit this to function regardless. + def get(self, req_options: Optional["RequestOptions"] = None) -> list[FlowRunItem]: # type: ignore[override] logger.info("Querying all flow runs on site") url = self.baseurl server_response = self.get_request(url, req_options) - pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) all_flow_run_items = FlowRunItem.from_response(server_response.content, self.parent_srv.namespace) - return all_flow_run_items, pagination_item + return all_flow_run_items # Get 1 flow by id @api(version="3.10") @@ -46,7 +48,7 @@ def get_by_id(self, flow_run_id: str) -> FlowRunItem: # Cancel 1 flow run by id @api(version="3.10") - def cancel(self, flow_run_id: str) -> None: + def cancel(self, flow_run_id: Union[str, FlowRunItem]) -> None: if not flow_run_id: error = "Flow ID undefined." raise ValueError(error) diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index e72b29ab2..feebc1a7e 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -1,9 +1,10 @@ -from collections.abc import Sized +from collections.abc import Iterable, Iterator, Sized from itertools import count from typing import Optional, Protocol, TYPE_CHECKING, TypeVar, overload -from collections.abc import Iterable, Iterator +import sys from tableauserverclient.config import config from tableauserverclient.models.pagination_item import PaginationItem +from tableauserverclient.server.endpoint.exceptions import ServerResponseError from tableauserverclient.server.filter import Filter from tableauserverclient.server.request_options import RequestOptions from tableauserverclient.server.sort import Sort @@ -35,6 +36,32 @@ def to_camel_case(word: str) -> str: class QuerySet(Iterable[T], Sized): + """ + QuerySet is a class that allows easy filtering, sorting, and iterating over + many endpoints in TableauServerClient. It is designed to be used in a similar + way to Django QuerySets, but with a more limited feature set. + + QuerySet is an iterable, and can be used in for loops, list comprehensions, + and other places where iterables are expected. + + QuerySet is also Sized, and can be used in places where the length of the + QuerySet is needed. The length of the QuerySet is the total number of items + available in the QuerySet, not just the number of items that have been + fetched. If the endpoint does not return a total count of items, the length + of the QuerySet will be sys.maxsize. If there is no total count, the + QuerySet will continue to fetch items until there are no more items to + fetch. + + QuerySet is not re-entrant. It is not designed to be used in multiple places + at the same time. If you need to use a QuerySet in multiple places, you + should create a new QuerySet for each place you need to use it, convert it + to a list, or create a deep copy of the QuerySet. + + QuerySets are also indexable, and can be sliced. If you try to access an + index that has not been fetched, the QuerySet will fetch the page that + contains the item you are looking for. + """ + def __init__(self, model: "QuerysetEndpoint[T]", page_size: Optional[int] = None) -> None: self.model = model self.request_options = RequestOptions(pagesize=page_size or config.PAGE_SIZE) @@ -50,10 +77,20 @@ def __iter__(self: Self) -> Iterator[T]: for page in count(1): self.request_options.pagenumber = page self._result_cache = [] - self._fetch_all() + try: + self._fetch_all() + except ServerResponseError as e: + if e.code == "400006": + # If the endpoint does not support pagination, it will end + # up overrunning the total number of pages. Catch the + # error and break out of the loop. + raise StopIteration yield from self._result_cache - # Set result_cache to empty so the fetch will populate - if (page * self.page_size) >= len(self): + # If the length of the QuerySet is unknown, continue fetching until + # the result cache is empty. + if (size := len(self)) == 0: + continue + if (page * self.page_size) >= size: return @overload @@ -114,10 +151,15 @@ 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) + response = self.model.get(self.request_options) + if isinstance(response, tuple): + self._result_cache, self._pagination_item = response + else: + self._result_cache = response + self._pagination_item = PaginationItem() def __len__(self: Self) -> int: - return self.total_available + return self.total_available or sys.maxsize @property def total_available(self: Self) -> int: @@ -127,12 +169,16 @@ def total_available(self: Self) -> int: @property def page_number(self: Self) -> int: self._fetch_all() - return self._pagination_item.page_number + # If the PaginationItem is not returned from the endpoint, use the + # pagenumber from the RequestOptions. + return self._pagination_item.page_number or self.request_options.pagenumber @property def page_size(self: Self) -> int: self._fetch_all() - return self._pagination_item.page_size + # If the PaginationItem is not returned from the endpoint, use the + # pagesize from the RequestOptions. + return self._pagination_item.page_size or self.request_options.pagesize def filter(self: Self, *invalid, page_size: Optional[int] = None, **kwargs) -> Self: if invalid: diff --git a/test/_utils.py b/test/_utils.py index 8527aaf8c..b4ee93bc3 100644 --- a/test/_utils.py +++ b/test/_utils.py @@ -1,5 +1,6 @@ import os.path import unittest +from xml.etree import ElementTree as ET from contextlib import contextmanager TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -18,6 +19,19 @@ def read_xml_assets(*args): return map(read_xml_asset, args) +def server_response_error_factory(code: str, summary: str, detail: str) -> str: + root = ET.Element("tsResponse") + error = ET.SubElement(root, "error") + error.attrib["code"] = code + + summary_element = ET.SubElement(error, "summary") + summary_element.text = summary + + detail_element = ET.SubElement(error, "detail") + detail_element.text = detail + return ET.tostring(root, encoding="utf-8").decode("utf-8") + + @contextmanager def mocked_time(): mock_time = 0 diff --git a/test/assets/flow_runs_get.xml b/test/assets/flow_runs_get.xml index bdce4cdfb..489e8ac63 100644 --- a/test/assets/flow_runs_get.xml +++ b/test/assets/flow_runs_get.xml @@ -1,5 +1,4 @@ - - \ No newline at end of file + diff --git a/test/test_flowruns.py b/test/test_flowruns.py index e1ddd5541..8af2540dc 100644 --- a/test/test_flowruns.py +++ b/test/test_flowruns.py @@ -1,3 +1,4 @@ +import sys import unittest import requests_mock @@ -5,7 +6,7 @@ import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime from tableauserverclient.server.endpoint.exceptions import FlowRunFailedException -from ._utils import read_xml_asset, mocked_time +from ._utils import read_xml_asset, mocked_time, server_response_error_factory GET_XML = "flow_runs_get.xml" GET_BY_ID_XML = "flow_runs_get_by_id.xml" @@ -28,9 +29,8 @@ def test_get(self) -> None: response_xml = read_xml_asset(GET_XML) with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) - all_flow_runs, pagination_item = self.server.flow_runs.get() + all_flow_runs = self.server.flow_runs.get() - self.assertEqual(2, pagination_item.total_available) self.assertEqual("cc2e652d-4a9b-4476-8c93-b238c45db968", all_flow_runs[0].id) self.assertEqual("2021-02-11T01:42:55Z", format_datetime(all_flow_runs[0].started_at)) self.assertEqual("2021-02-11T01:57:38Z", format_datetime(all_flow_runs[0].completed_at)) @@ -98,3 +98,14 @@ def test_wait_for_job_timeout(self) -> None: m.get(f"{self.baseurl}/{flow_run_id}", text=response_xml) with self.assertRaises(TimeoutError): self.server.flow_runs.wait_for_job(flow_run_id, timeout=30) + + def test_queryset(self) -> None: + response_xml = read_xml_asset(GET_XML) + error_response = server_response_error_factory( + "400006", "Bad Request", "0xB4EAB088 : The start index '9900' is greater than or equal to the total count.)" + ) + with requests_mock.mock() as m: + m.get(f"{self.baseurl}?pageNumber=1", text=response_xml) + m.get(f"{self.baseurl}?pageNumber=2", text=error_response) + queryset = self.server.flow_runs.all() + assert len(queryset) == sys.maxsize From 9a310040c8d9da5f762cbe0bf62653619fff521b Mon Sep 17 00:00:00 2001 From: Jac Date: Sat, 28 Sep 2024 11:32:20 -0700 Subject: [PATCH 236/296] #1464 - docs update for filtering on boolean values (#1471) Add docs mention of boolean values for filtering --- tableauserverclient/server/request_options.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index fedf3ab45..a3ad0c498 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -164,13 +164,14 @@ def get_query_params(self): raise NotImplementedError() 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.""" + """Apply a filter based on a column within the view. + Note that when filtering on a boolean type field, the only valid values are 'true' and 'false'""" self.view_filters.append((name, value)) return self def parameter(self, name: str, value: str) -> Self: - """Apply a filter based on a parameter within the workbook.""" + """Apply a filter based on a parameter within the workbook. + Note that when filtering on a boolean type field, the only valid values are 'true' and 'false'""" self.view_parameters.append((name, value)) return self From d480b7570c6d0db06ef7ac4cc8ab352c7c448807 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 30 Sep 2024 00:27:59 -0500 Subject: [PATCH 237/296] chore(versions): update remaining f-strings (#1477) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/_version.py | 4 ++-- tableauserverclient/server/endpoint/endpoint.py | 2 +- test/test_schedule.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tableauserverclient/_version.py b/tableauserverclient/_version.py index 5d1dca9df..79dbed1d8 100644 --- a/tableauserverclient/_version.py +++ b/tableauserverclient/_version.py @@ -94,7 +94,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= return None, None else: if verbose: - print("unable to find command, tried {}".format(commands)) + print(f"unable to find command, tried {commands}") return None, None stdout = p.communicate()[0].strip() if sys.version_info[0] >= 3: @@ -131,7 +131,7 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): root = os.path.dirname(root) # up a level if verbose: - print("Tried directories {} but none started with prefix {}".format(str(rootdirs), parentdir_prefix)) + print(f"Tried directories {str(rootdirs)} but none started with prefix {parentdir_prefix}") raise NotThisMethod("rootdir doesn't start with parentdir_prefix") diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index bef96fdee..29912de63 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -140,7 +140,7 @@ def _make_request( self._check_status(server_response, url) loggable_response = self.log_response_safely(server_response) - logger.debug("Server response from {0}".format(url)) + logger.debug(f"Server response from {url}") # uncomment the following to log full responses in debug mode # BE CAREFUL WHEN SHARING THESE RESULTS - MAY CONTAIN YOUR SENSITIVE DATA # logger.debug(loggable_response) diff --git a/test/test_schedule.py b/test/test_schedule.py index 1d329f86e..b072522a4 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -165,7 +165,7 @@ def test_get_monthly_by_id_2(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "8c5caf33-6223-4724-83c3-ccdc1e730a07" - baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) From e1b828120bd7164b3f20be43123335a977293784 Mon Sep 17 00:00:00 2001 From: Jac Date: Mon, 30 Sep 2024 14:46:00 -0700 Subject: [PATCH 238/296] #1475 Add 'description' to datasource sample code (#1475) Update explore_datasource.py --- samples/explore_datasource.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/samples/explore_datasource.py b/samples/explore_datasource.py index 877c5f08d..c9f35d5be 100644 --- a/samples/explore_datasource.py +++ b/samples/explore_datasource.py @@ -51,6 +51,7 @@ def main(): if args.publish: if default_project is not None: new_datasource = TSC.DatasourceItem(default_project.id) + new_datasource.description = "Published with a description" new_datasource = server.datasources.publish( new_datasource, args.publish, TSC.Server.PublishMode.Overwrite ) @@ -72,6 +73,10 @@ def main(): print(f"\nConnections for {sample_datasource.name}: ") print([f"{connection.id}({connection.datasource_name})" for connection in sample_datasource.connections]) + # Demonstrate that description is editable + sample_datasource.description = "Description updated by TSC" + server.datasources.update(sample_datasource) + # Add some tags to the datasource original_tag_set = set(sample_datasource.tags) sample_datasource.tags.update("a", "b", "c", "d") From b49eac5766e61260c6f8ba56d2671b8f44f5b1b1 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 10 Oct 2024 14:09:07 -0500 Subject: [PATCH 239/296] feat(exceptions): separate failed signin error (#1478) * feat(exceptions): separate failed signin error Closes #1472 This makes sign in failures their own class of exceptions, while still inheriting from NotSignedInException to not break backwards compatability for any existing client code. This should allow users to get out more specific exceptions more easily on what failed with their authentication request. * fix(error): raise exception when ServerInfo.get fails If ServerInfoItem.from_response gets invalid XML, raise the error immediately instead of suppressing the error and setting an invalid version number * fix(test): add missing test asset --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/__init__.py | 2 + .../models/server_info_item.py | 8 +-- tableauserverclient/server/__init__.py | 3 +- .../server/endpoint/endpoint.py | 3 +- .../server/endpoint/exceptions.py | 24 ++++++-- test/assets/server_info_wrong_site.html | 56 +++++++++++++++++++ test/test_auth.py | 6 +- test/test_server_info.py | 10 ++++ 8 files changed, 98 insertions(+), 14 deletions(-) create mode 100644 test/assets/server_info_wrong_site.html diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index bab2cf05f..1299c33bc 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -56,6 +56,7 @@ PDFRequestOptions, RequestOptions, MissingRequiredFieldError, + FailedSignInError, NotSignedInError, ServerResponseError, Filter, @@ -79,6 +80,7 @@ "DatabaseItem", "DataFreshnessPolicyItem", "DatasourceItem", + "FailedSignInError", "FavoriteItem", "FlowItem", "FlowRunItem", diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py index 5c3f6acc7..4b299b29d 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -40,13 +40,11 @@ def from_response(cls, resp, ns): try: parsed_response = fromstring(resp) except xml.etree.ElementTree.ParseError as error: - logger.info(f"Unexpected response for ServerInfo: {resp}") - logger.info(error) + logger.exception(f"Unexpected response for ServerInfo: {resp}") return cls("Unknown", "Unknown", "Unknown") except Exception as error: - logger.info(f"Unexpected response for ServerInfo: {resp}") - logger.info(error) - return cls("Unknown", "Unknown", "Unknown") + logger.exception(f"Unexpected response for ServerInfo: {resp}") + raise error product_version_tag = parsed_response.find(".//t:productVersion", namespaces=ns) rest_api_version_tag = parsed_response.find(".//t:restApiVersion", namespaces=ns) diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index f5cd1d236..87cc9460b 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -11,7 +11,7 @@ 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 tableauserverclient.server.endpoint.exceptions import FailedSignInError, NotSignedInError from tableauserverclient.server.endpoint import ( Auth, @@ -57,6 +57,7 @@ "Sort", "Server", "Pager", + "FailedSignInError", "NotSignedInError", "Auth", "CustomViews", diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 29912de63..9e1160705 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -19,6 +19,7 @@ from tableauserverclient.server.request_options import RequestOptions from tableauserverclient.server.endpoint.exceptions import ( + FailedSignInError, ServerResponseError, InternalServerError, NonXMLResponseError, @@ -160,7 +161,7 @@ def _check_status(self, server_response: "Response", url: Optional[str] = None): try: if server_response.status_code == 401: # TODO: catch this in server.py and attempt to sign in again, in case it's a session expiry - raise NotSignedInError(server_response.content, url) + raise FailedSignInError.from_response(server_response.content, self.parent_srv.namespace, url) raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace, url) except ParseError: diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 17d789d01..77332da3e 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -1,13 +1,20 @@ from defusedxml.ElementTree import fromstring -from typing import Optional +from typing import Mapping, Optional, TypeVar + + +def split_pascal_case(s: str) -> str: + return "".join([f" {c}" if c.isupper() else c for c in s]).strip() class TableauError(Exception): pass -class ServerResponseError(TableauError): - def __init__(self, code, summary, detail, url=None): +T = TypeVar("T") + + +class XMLError(TableauError): + def __init__(self, code: str, summary: str, detail: str, url: Optional[str] = None) -> None: self.code = code self.summary = summary self.detail = detail @@ -18,7 +25,7 @@ def __str__(self): return f"\n\n\t{self.code}: {self.summary}\n\t\t{self.detail}" @classmethod - def from_response(cls, resp, ns, url=None): + def from_response(cls, resp, ns, url): # Check elements exist before .text parsed_response = fromstring(resp) try: @@ -33,6 +40,10 @@ def from_response(cls, resp, ns, url=None): return error_response +class ServerResponseError(XMLError): + pass + + class InternalServerError(TableauError): def __init__(self, server_response, request_url: Optional[str] = None): self.code = server_response.status_code @@ -51,6 +62,11 @@ class NotSignedInError(TableauError): pass +class FailedSignInError(XMLError, NotSignedInError): + def __str__(self): + return f"{split_pascal_case(self.__class__.__name__)}: {super().__str__()}" + + class ItemTypeNotAllowed(TableauError): pass diff --git a/test/assets/server_info_wrong_site.html b/test/assets/server_info_wrong_site.html new file mode 100644 index 000000000..e92daeb2d --- /dev/null +++ b/test/assets/server_info_wrong_site.html @@ -0,0 +1,56 @@ + + + + + + Example website + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ABCDE
12345
23456
34567
45678
56789
+ + + \ No newline at end of file diff --git a/test/test_auth.py b/test/test_auth.py index eaf13481e..48100ad88 100644 --- a/test/test_auth.py +++ b/test/test_auth.py @@ -63,7 +63,7 @@ def test_sign_in_error(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.TableauAuth("testuser", "wrongpassword") - self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.FailedSignInError, self.server.auth.sign_in, tableau_auth) def test_sign_in_invalid_token(self): with open(SIGN_IN_ERROR_XML, "rb") as f: @@ -71,7 +71,7 @@ def test_sign_in_invalid_token(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.PersonalAccessTokenAuth(token_name="mytoken", personal_access_token="invalid") - self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.FailedSignInError, self.server.auth.sign_in, tableau_auth) def test_sign_in_without_auth(self): with open(SIGN_IN_ERROR_XML, "rb") as f: @@ -79,7 +79,7 @@ def test_sign_in_without_auth(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.TableauAuth("", "") - self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.FailedSignInError, self.server.auth.sign_in, tableau_auth) def test_sign_out(self): with open(SIGN_IN_XML, "rb") as f: diff --git a/test/test_server_info.py b/test/test_server_info.py index 1cf190ecd..fa1472c9a 100644 --- a/test/test_server_info.py +++ b/test/test_server_info.py @@ -4,6 +4,7 @@ import requests_mock import tableauserverclient as TSC +from tableauserverclient.server.endpoint.exceptions import NonXMLResponseError TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -11,6 +12,7 @@ SERVER_INFO_25_XML = os.path.join(TEST_ASSET_DIR, "server_info_25.xml") SERVER_INFO_404 = os.path.join(TEST_ASSET_DIR, "server_info_404.xml") SERVER_INFO_AUTH_INFO_XML = os.path.join(TEST_ASSET_DIR, "server_info_auth_info.xml") +SERVER_INFO_WRONG_SITE = os.path.join(TEST_ASSET_DIR, "server_info_wrong_site.html") class ServerInfoTests(unittest.TestCase): @@ -63,3 +65,11 @@ def test_server_use_server_version_flag(self): m.get("http://test/api/2.4/serverInfo", text=si_response_xml) server = TSC.Server("http://test", use_server_version=True) self.assertEqual(server.version, "2.5") + + def test_server_wrong_site(self): + with open(SERVER_INFO_WRONG_SITE, "rb") as f: + response = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get(self.server.server_info.baseurl, text=response, status_code=404) + with self.assertRaises(NonXMLResponseError): + self.server.server_info.get() From 9495fe8109aa30bab27dc262a10666ce8f55eb5c Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 10 Oct 2024 14:36:33 -0500 Subject: [PATCH 240/296] docs: add docstrings to auth objects and endpoints (#1484) * docs: add docstrings to auth objects and endpoints * docs: add parameters and examples to methods --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/tableau_auth.py | 110 ++++++++++++++++++ .../server/endpoint/auth_endpoint.py | 57 +++++++++ 2 files changed, 167 insertions(+) diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index c1e9d62bf..7d7981433 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -32,6 +32,43 @@ def deprecate_site_attribute(): # The traditional auth type: username/password class TableauAuth(Credentials): + """ + The TableauAuth class defines the information you can set in a sign-in + request. The class members correspond to the attributes of a server request + or response payload. To use this class, create a new instance, supplying + user name, password, and site information if necessary, and pass the + request object to the Auth.sign_in method. + + Parameters + ---------- + username : str + The user name for the sign-in request. + + password : str + The password for the sign-in request. + + site_id : str, optional + This corresponds to the contentUrl attribute in the Tableau REST API. + The site_id is the portion of the URL that follows the /site/ in the + URL. For example, "MarketingTeam" is the site_id in the following URL + MyServer/#/site/MarketingTeam/projects. To specify the default site on + Tableau Server, you can use an empty string '' (single quotes, no + space). For Tableau Cloud, you must provide a value for the site_id. + + user_id_to_impersonate : str, optional + Specifies the id (not the name) of the user to sign in as. This is not + available for Tableau Online. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD', site_id='CONTENTURL') + >>> server = TSC.Server('https://SERVER_URL', use_server_version=True) + >>> server.auth.sign_in(tableau_auth) + + """ + def __init__( self, username: str, password: str, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None ) -> None: @@ -55,6 +92,43 @@ def __repr__(self): # A Tableau-generated Personal Access Token class PersonalAccessTokenAuth(Credentials): + """ + The PersonalAccessTokenAuth class defines the information you can set in a sign-in + request. The class members correspond to the attributes of a server request + or response payload. To use this class, create a new instance, supplying + token name, token secret, and site information if necessary, and pass the + request object to the Auth.sign_in method. + + Parameters + ---------- + token_name : str + The name of the personal access token. + + personal_access_token : str + The personal access token secret for the sign in request. + + site_id : str, optional + This corresponds to the contentUrl attribute in the Tableau REST API. + The site_id is the portion of the URL that follows the /site/ in the + URL. For example, "MarketingTeam" is the site_id in the following URL + MyServer/#/site/MarketingTeam/projects. To specify the default site on + Tableau Server, you can use an empty string '' (single quotes, no + space). For Tableau Cloud, you must provide a value for the site_id. + + user_id_to_impersonate : str, optional + Specifies the id (not the name) of the user to sign in as. This is not + available for Tableau Online. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> tableau_auth = TSC.PersonalAccessTokenAuth("token_name", "token_secret", site_id='CONTENTURL') + >>> server = TSC.Server('https://SERVER_URL', use_server_version=True) + >>> server.auth.sign_in(tableau_auth) + + """ + def __init__( self, token_name: str, @@ -88,6 +162,42 @@ def __repr__(self): # A standard JWT generated specifically for Tableau class JWTAuth(Credentials): + """ + The JWTAuth class defines the information you can set in a sign-in + request. The class members correspond to the attributes of a server request + or response payload. To use this class, create a new instance, supplying + an encoded JSON Web Token, and site information if necessary, and pass the + request object to the Auth.sign_in method. + + Parameters + ---------- + token : str + The encoded JSON Web Token. + + site_id : str, optional + This corresponds to the contentUrl attribute in the Tableau REST API. + The site_id is the portion of the URL that follows the /site/ in the + URL. For example, "MarketingTeam" is the site_id in the following URL + MyServer/#/site/MarketingTeam/projects. To specify the default site on + Tableau Server, you can use an empty string '' (single quotes, no + space). For Tableau Cloud, you must provide a value for the site_id. + + user_id_to_impersonate : str, optional + Specifies the id (not the name) of the user to sign in as. This is not + available for Tableau Online. + + Examples + -------- + >>> import jwt + >>> import tableauserverclient as TSC + + >>> jwt_token = jwt.encode(...) + >>> tableau_auth = TSC.JWTAuth(token, site_id='CONTENTURL') + >>> server = TSC.Server('https://SERVER_URL', use_server_version=True) + >>> server.auth.sign_in(tableau_auth) + + """ + 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") diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 231052f73..4211bb7ea 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -41,6 +41,30 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr: optionally a user_id to impersonate. Creates a context manager that will sign out of the server upon exit. + + Parameters + ---------- + auth_req : Credentials + The credentials object to use for signing in. Can be a TableauAuth, + PersonalAccessTokenAuth, or JWTAuth object. + + Returns + ------- + contextmgr + A context manager that will sign out of the server upon exit. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> # create an auth object + >>> tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') + + >>> # create an instance for your server + >>> server = TSC.Server('https://SERVER_URL') + + >>> # call the sign-in method with the auth object + >>> server.auth.sign_in(tableau_auth) """ url = f"{self.baseurl}/signin" signin_req = RequestFactory.Auth.signin_req(auth_req) @@ -70,14 +94,17 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr: # The distinct methods are mostly useful for explicitly showing api version support for each auth type @api(version="3.6") def sign_in_with_personal_access_token(self, auth_req: "Credentials") -> contextmgr: + """Passthrough to sign_in method""" return self.sign_in(auth_req) @api(version="3.17") def sign_in_with_json_web_token(self, auth_req: "Credentials") -> contextmgr: + """Passthrough to sign_in method""" return self.sign_in(auth_req) @api(version="2.0") def sign_out(self) -> None: + """Sign out of current session.""" url = f"{self.baseurl}/signout" # If there are no auth tokens you're already signed out. No-op if not self.parent_srv.is_signed_in(): @@ -88,6 +115,33 @@ def sign_out(self) -> None: @api(version="2.6") def switch_site(self, site_item: "SiteItem") -> contextmgr: + """ + Switch to a different site on the server. This will sign out of the + current site and sign in to the new site. If used as a context manager, + will sign out of the new site upon exit. + + Parameters + ---------- + site_item : SiteItem + The site to switch to. + + Returns + ------- + contextmgr + A context manager that will sign out of the new site upon exit. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> # Find the site you want to switch to + >>> new_site = server.sites.get_by_id("9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d") + >>> # switch to the new site + >>> with server.auth.switch_site(new_site): + >>> # do something on the new site + >>> pass + + """ url = f"{self.baseurl}/switchSite" switch_req = RequestFactory.Auth.switch_req(site_item.content_url) try: @@ -109,6 +163,9 @@ def switch_site(self, site_item: "SiteItem") -> contextmgr: @api(version="3.10") def revoke_all_server_admin_tokens(self) -> None: + """ + Revokes all personal access tokens for all server admins on the server. + """ url = f"{self.baseurl}/revokeAllServerAdminTokens" self.post_request(url, "") logger.info("Revoked all tokens for all server admins") From 0af55124903e3902e37e5cb8126cdcf5a39f3aa2 Mon Sep 17 00:00:00 2001 From: Henning Merklinger Date: Thu, 10 Oct 2024 22:23:05 +0200 Subject: [PATCH 241/296] Set FILESIZE_LIMIT_MB via environment variables (#1466) * add TSC_FILESIZE_LIMIT_MB environment variable * add hard limit for filesize limit at 64MB * fix formatting --------- Co-authored-by: Jac --- tableauserverclient/config.py | 8 +++++--- .../server/endpoint/custom_views_endpoint.py | 4 ++-- .../server/endpoint/datasources_endpoint.py | 6 +++--- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/tableauserverclient/config.py b/tableauserverclient/config.py index 63872398f..a75112754 100644 --- a/tableauserverclient/config.py +++ b/tableauserverclient/config.py @@ -6,11 +6,13 @@ DELAY_SLEEP_SECONDS = 0.1 -# The maximum size of a file that can be published in a single request is 64MB -FILESIZE_LIMIT_MB = 64 - class Config: + # The maximum size of a file that can be published in a single request is 64MB + @property + def FILESIZE_LIMIT_MB(self): + return min(int(os.getenv("TSC_FILESIZE_LIMIT_MB", 64)), 64) + # For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks @property def CHUNK_SIZE_MB(self): diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index baed91149..63899ba0c 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import Optional, Union -from tableauserverclient.config import BYTES_PER_MB, FILESIZE_LIMIT_MB +from tableauserverclient.config import BYTES_PER_MB, config from tableauserverclient.filesys_helpers import get_file_object_size from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError @@ -144,7 +144,7 @@ def publish(self, view_item: CustomViewItem, file: PathOrFileR) -> Optional[Cust else: raise ValueError("File path or file object required for publishing custom view.") - if size >= FILESIZE_LIMIT_MB * BYTES_PER_MB: + if size >= config.FILESIZE_LIMIT_MB * BYTES_PER_MB: upload_session_id = self.parent_srv.fileuploads.upload(file) url = f"{url}?uploadSessionId={upload_session_id}" xml_request, content_type = RequestFactory.CustomView.publish_req_chunked(view_item) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 38ef50751..6bd809c28 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -23,7 +23,7 @@ from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin -from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, FILESIZE_LIMIT_MB, BYTES_PER_MB, config +from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, BYTES_PER_MB, config from tableauserverclient.filesys_helpers import ( make_download_path, get_file_type, @@ -268,10 +268,10 @@ def publish( url += "&{}=true".format("asJob") # Determine if chunking is required (64MB is the limit for single upload method) - if file_size >= FILESIZE_LIMIT_MB * BYTES_PER_MB: + if file_size >= config.FILESIZE_LIMIT_MB * BYTES_PER_MB: logger.info( "Publishing {} to server with chunking method (datasource over {}MB, chunk size {}MB)".format( - filename, FILESIZE_LIMIT_MB, config.CHUNK_SIZE_MB + filename, config.FILESIZE_LIMIT_MB, config.CHUNK_SIZE_MB ) ) upload_session_id = self.parent_srv.fileuploads.upload(file) From c6dabddd993339b8a0d5edbc820234636f62914b Mon Sep 17 00:00:00 2001 From: AlbertWangXu Date: Thu, 10 Oct 2024 18:12:55 -0400 Subject: [PATCH 242/296] added PulseMetricDefine cap (#1490) Update permissions_item.py added PulseMetricDefine cap Co-authored-by: Jac --- tableauserverclient/models/permissions_item.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 3e4fec22a..186cebedd 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -41,6 +41,7 @@ class Capability: RunExplainData = "RunExplainData" CreateRefreshMetrics = "CreateRefreshMetrics" SaveAs = "SaveAs" + PulseMetricDefine = "PulseMetricDefine" def __repr__(self): return "" From 0efd7357d494141c17dda508252e884b8f772f5f Mon Sep 17 00:00:00 2001 From: TrimPeachu <77048868+TrimPeachu@users.noreply.github.com> Date: Fri, 11 Oct 2024 00:14:17 +0200 Subject: [PATCH 243/296] Adding project permissions handling for databases, tables and virtual connections (#1482) --- tableauserverclient/models/project_item.py | 44 +++++++++++++------ .../server/endpoint/projects_endpoint.py | 36 +++++++++++++++ 2 files changed, 66 insertions(+), 14 deletions(-) diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index d875abbdf..48f27c60c 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -9,6 +9,8 @@ class ProjectItem: + ERROR_MSG = "Project item must be populated with permissions first." + class ContentPermissions: LockedToProject: str = "LockedToProject" ManagedByOwner: str = "ManagedByOwner" @@ -43,6 +45,9 @@ def __init__( self._default_lens_permissions = None self._default_datarole_permissions = None self._default_metric_permissions = None + self._default_virtualconnection_permissions = None + self._default_database_permissions = None + self._default_table_permissions = None @property def content_permissions(self): @@ -56,52 +61,63 @@ def content_permissions(self, value: Optional[str]) -> None: @property def permissions(self): if self._permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._permissions() @property def default_datasource_permissions(self): if self._default_datasource_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_datasource_permissions() @property def default_workbook_permissions(self): if self._default_workbook_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_workbook_permissions() @property def default_flow_permissions(self): if self._default_flow_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_flow_permissions() @property def default_lens_permissions(self): if self._default_lens_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_lens_permissions() @property def default_datarole_permissions(self): if self._default_datarole_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_datarole_permissions() @property def default_metric_permissions(self): if self._default_metric_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_metric_permissions() + @property + def default_virtualconnection_permissions(self): + if self._default_virtualconnection_permissions is None: + raise UnpopulatedPropertyError(self.ERROR_MSG) + return self._default_virtualconnection_permissions() + + @property + def default_database_permissions(self): + if self._default_database_permissions is None: + raise UnpopulatedPropertyError(self.ERROR_MSG) + return self._default_database_permissions() + + @property + def default_table_permissions(self): + if self._default_table_permissions is None: + raise UnpopulatedPropertyError(self.ERROR_MSG) + return self._default_table_permissions() + @property def id(self) -> Optional[str]: return self._id diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 4d139fe66..773b942de 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -109,6 +109,18 @@ def populate_flow_default_permissions(self, item): def populate_lens_default_permissions(self, item): self._default_permissions.populate_default_permissions(item, Resource.Lens) + @api(version="3.23") + def populate_virtualconnection_default_permissions(self, item): + self._default_permissions.populate_default_permissions(item, Resource.VirtualConnection) + + @api(version="3.23") + def populate_database_default_permissions(self, item): + self._default_permissions.populate_default_permissions(item, Resource.Database) + + @api(version="3.23") + def populate_table_default_permissions(self, item): + self._default_permissions.populate_default_permissions(item, Resource.Table) + @api(version="2.1") def update_workbook_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Resource.Workbook) @@ -133,6 +145,18 @@ def update_flow_default_permissions(self, item, rules): def update_lens_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Resource.Lens) + @api(version="3.23") + def update_virtualconnection_default_permissions(self, item, rules): + return self._default_permissions.update_default_permissions(item, rules, Resource.VirtualConnection) + + @api(version="3.23") + def update_database_default_permissions(self, item, rules): + return self._default_permissions.update_default_permissions(item, rules, Resource.Database) + + @api(version="3.23") + def update_table_default_permissions(self, item, rules): + return self._default_permissions.update_default_permissions(item, rules, Resource.Table) + @api(version="2.1") def delete_workbook_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Resource.Workbook) @@ -157,6 +181,18 @@ def delete_flow_default_permissions(self, item, rule): def delete_lens_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Resource.Lens) + @api(version="3.23") + def delete_virtualconnection_default_permissions(self, item, rule): + self._default_permissions.delete_default_permission(item, rule, Resource.VirtualConnection) + + @api(version="3.23") + def delete_database_default_permissions(self, item, rule): + self._default_permissions.delete_default_permission(item, rule, Resource.Database) + + @api(version="3.23") + def delete_table_default_permissions(self, item, rule): + self._default_permissions.delete_default_permission(item, rule, Resource.Table) + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[ProjectItem]: """ Queries the Tableau Server for items using the specified filters. Page From f8728b211d8e8eb4507f6e907e482da5a60d3577 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 11 Oct 2024 15:58:32 -0500 Subject: [PATCH 244/296] docs: docstrings for Server and ServerInfo (#1494) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- .../models/server_info_item.py | 22 ++++++++ .../server/endpoint/server_info_endpoint.py | 41 +++++++++++++- tableauserverclient/server/server.py | 56 +++++++++++++++++++ 3 files changed, 116 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py index 4b299b29d..b13f26740 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -7,6 +7,28 @@ class ServerInfoItem: + """ + The ServerInfoItem class contains the build and version information for + Tableau Server. The server information is accessed with the + server_info.get() method, which returns an instance of the ServerInfo class. + + Attributes + ---------- + product_version : str + Shows the version of the Tableau Server or Tableau Cloud + (for example, 10.2.0). + + build_number : str + Shows the specific build number (for example, 10200.17.0329.1446). + + rest_api_version : str + Shows the supported REST API version number. Note that this might be + different from the default value specified for the server, with the + Server.version attribute. To take advantage of new features, you should + query the server and set the Server.version to match the supported REST + API version number. + """ + def __init__(self, product_version, build_number, rest_api_version): self._product_version = product_version self._build_number = build_number diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index ab731c11b..dc934496a 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -1,4 +1,5 @@ import logging +from typing import Union from .endpoint import Endpoint, api from .exceptions import ServerResponseError @@ -24,12 +25,46 @@ def __repr__(self): return f"" @property - def baseurl(self): + def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/serverInfo" @api(version="2.4") - def get(self): - """Retrieve the server info for the server. This is an unauthenticated call""" + def get(self) -> Union[ServerInfoItem, None]: + """ + Retrieve the build and version information for the server. + + This method makes an unauthenticated call, so no sign in or + authentication token is required. + + Returns + ------- + :class:`~tableauserverclient.models.ServerInfoItem` + + Raises + ------ + :class:`~tableauserverclient.exceptions.ServerInfoEndpointNotFoundError` + Raised when the server info endpoint is not found. + + :class:`~tableauserverclient.exceptions.EndpointUnavailableError` + Raised when the server info endpoint is not available. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> # create a instance of server + >>> server = TSC.Server('https://MY-SERVER') + + >>> # set the version number > 2.3 + >>> # the server_info.get() method works in 2.4 and later + >>> server.version = '2.5' + + >>> s_info = server.server_info.get() + >>> print("\nServer info:") + >>> print("\tProduct version: {0}".format(s_info.product_version)) + >>> print("\tREST API version: {0}".format(s_info.rest_api_version)) + >>> print("\tBuild number: {0}".format(s_info.build_number)) + """ try: server_response = self.get_unauthenticated_request(self.baseurl) except ServerResponseError as e: diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index dab5911db..4eeefcaf9 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -59,7 +59,63 @@ class Server: + """ + In the Tableau REST API, the server (https://MY-SERVER/) is the base or core + of the URI that makes up the various endpoints or methods for accessing + resources on the server (views, workbooks, sites, users, data sources, etc.) + The TSC library provides a Server class that represents the server. You + create a server instance to sign in to the server and to call the various + methods for accessing resources. + + The Server class contains the attributes that represent the server on + Tableau Server. After you create an instance of the Server class, you can + sign in to the server and call methods to access all of the resources on the + server. + + Parameters + ---------- + server_address : str + Specifies the address of the Tableau Server or Tableau Cloud (for + example, https://MY-SERVER/). + + use_server_version : bool + Specifies the version of the REST API to use (for example, '2.5'). When + you use the TSC library to call methods that access Tableau Server, the + version is passed to the endpoint as part of the URI + (https://MY-SERVER/api/2.5/). Each release of Tableau Server supports + specific versions of the REST API. New versions of the REST API are + released with Tableau Server. By default, the value of version is set to + '2.3', which corresponds to Tableau Server 10.0. You can view or set + this value. You might need to set this to a different value, for + example, if you want to access features that are supported by the server + and a later version of the REST API. For more information, see REST API + Versions. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> # create a instance of server + >>> server = TSC.Server('https://MY-SERVER') + + >>> # sign in, etc. + + >>> # change the REST API version to match the server + >>> server.use_server_version() + + >>> # or change the REST API version to match a specific version + >>> # for example, 2.8 + >>> # server.version = '2.8' + + """ + class PublishMode: + """ + Enumerates the options that specify what happens when you publish a + workbook or data source. The options are Overwrite, Append, or + CreateNew. + """ + Append = "Append" Overwrite = "Overwrite" CreateNew = "CreateNew" From 89e1ddf9aa3c509426acd7d247fcd1842ace996c Mon Sep 17 00:00:00 2001 From: Jac Date: Fri, 11 Oct 2024 14:00:13 -0700 Subject: [PATCH 245/296] refactor request_options, add language param (#1481) * refactor request_options, add language param I have refactored the classes to separate options that can be used in querying content, and options that can be used for exporting data. "language" is only available for data exporting. --- pyproject.toml | 2 +- samples/export.py | 14 +- tableauserverclient/__init__.py | 48 +++-- tableauserverclient/server/request_options.py | 199 ++++++++---------- test/test_request_option.py | 10 + 5 files changed, 129 insertions(+), 144 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c3cb67eda..67faefbe1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,13 +43,13 @@ target-version = ['py39', 'py310', 'py311', 'py312', 'py313'] check_untyped_defs = false disable_error_code = [ 'misc', - # 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", "samples"] show_error_codes = true ignore_missing_imports = true # defusedxml library has no types no_implicit_reexport = true +implicit_optional = true [tool.pytest.ini_options] testpaths = ["test"] diff --git a/samples/export.py b/samples/export.py index 815ec8b51..e33710468 100644 --- a/samples/export.py +++ b/samples/export.py @@ -37,7 +37,9 @@ def main(): "--csv", dest="type", action="store_const", const=("populate_csv", "CSVRequestOptions", "csv", "csv") ) # other options shown in explore_workbooks: workbook.download, workbook.preview_image - + parser.add_argument( + "--language", help="Text such as 'Average' will appear in this language. Use values like fr, de, es, en" + ) parser.add_argument("--workbook", action="store_true") parser.add_argument("--file", "-f", help="filename to store the exported data") @@ -74,16 +76,18 @@ def main(): populate = getattr(server.workbooks, populate_func_name) option_factory = getattr(TSC, option_factory_name) + options: TSC.PDFRequestOptions = option_factory() if args.filter: - options = option_factory().vf(*args.filter.split(":")) - else: - options = None + options = options.vf(*args.filter.split(":")) + + if args.language: + options.language = args.language if args.file: filename = args.file else: - filename = f"out.{extension}" + filename = f"out-{options.language}.{extension}" populate(item, options) with open(filename, "wb") as f: diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 1299c33bc..e0a7abb64 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -32,11 +32,13 @@ PermissionsRule, PersonalAccessTokenAuth, ProjectItem, + Resource, RevisionItem, ScheduleItem, SiteItem, ServerInfoItem, SubscriptionItem, + TableauItem, TableItem, TableauAuth, Target, @@ -66,66 +68,68 @@ ) __all__ = [ - "get_versions", - "DEFAULT_NAMESPACE", "BackgroundJobItem", "BackgroundJobItem", "ColumnItem", "ConnectionCredentials", "ConnectionItem", + "CSVRequestOptions", "CustomViewItem", - "DQWItem", "DailyInterval", "DataAlertItem", "DatabaseItem", "DataFreshnessPolicyItem", "DatasourceItem", + "DEFAULT_NAMESPACE", + "DQWItem", + "ExcelRequestOptions", "FailedSignInError", "FavoriteItem", + "FileuploadItem", + "Filter", "FlowItem", "FlowRunItem", - "FileuploadItem", + "get_versions", "GroupItem", "GroupSetItem", "HourlyInterval", + "ImageRequestOptions", "IntervalItem", "JobItem", "JWTAuth", + "LinkedTaskFlowRunItem", + "LinkedTaskItem", + "LinkedTaskStepItem", "MetricItem", + "MissingRequiredFieldError", "MonthlyInterval", + "NotSignedInError", + "Pager", "PaginationItem", + "PDFRequestOptions", "Permission", "PermissionsRule", "PersonalAccessTokenAuth", "ProjectItem", + "RequestOptions", + "Resource", "RevisionItem", "ScheduleItem", - "SiteItem", + "Server", "ServerInfoItem", + "ServerResponseError", + "SiteItem", + "Sort", "SubscriptionItem", - "TableItem", "TableauAuth", + "TableauItem", + "TableItem", "Target", "TaskItem", "UserItem", "ViewItem", + "VirtualConnectionItem", "WebhookItem", "WeeklyInterval", "WorkbookItem", - "CSVRequestOptions", - "ExcelRequestOptions", - "ImageRequestOptions", - "PDFRequestOptions", - "RequestOptions", - "MissingRequiredFieldError", - "NotSignedInError", - "ServerResponseError", - "Filter", - "Pager", - "Server", - "Sort", - "LinkedTaskItem", - "LinkedTaskStepItem", - "LinkedTaskFlowRunItem", - "VirtualConnectionItem", ] diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index a3ad0c498..0d47abfcc 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,4 +1,5 @@ import sys +from typing import Optional from typing_extensions import Self @@ -26,11 +27,48 @@ def apply_query_params(self, url): except NotImplementedError: raise - def get_query_params(self): - raise NotImplementedError() + +# If it wasn't a breaking change, I'd rename it to QueryOptions +""" +This class manages options can be used when querying content on the server +""" class RequestOptions(RequestOptionsBase): + def __init__(self, pagenumber=1, pagesize=None): + self.pagenumber = pagenumber + self.pagesize = pagesize or config.PAGE_SIZE + self.sort = set() + self.filter = set() + # This is private until we expand all of our parsers to handle the extra fields + self._all_fields = False + + def get_query_params(self) -> dict: + params = {} + if self.sort and len(self.sort) > 0: + sort_options = (str(sort_item) for sort_item in self.sort) + ordered_sort_options = sorted(sort_options) + params["sort"] = ",".join(ordered_sort_options) + if len(self.filter) > 0: + filter_options = (str(filter_item) for filter_item in self.filter) + ordered_filter_options = sorted(filter_options) + params["filter"] = ",".join(ordered_filter_options) + if self._all_fields: + params["fields"] = "_all_" + if self.pagenumber: + params["pageNumber"] = self.pagenumber + if self.pagesize: + params["pageSize"] = self.pagesize + return params + + def page_size(self, page_size): + self.pagesize = page_size + return self + + def page_number(self, page_number): + self.pagenumber = page_number + return self + class Operator: Equals = "eq" GreaterThan = "gt" @@ -41,6 +79,7 @@ class Operator: Has = "has" CaseInsensitiveEquals = "cieq" + # These are fields in the REST API class Field: Args = "args" AuthenticationType = "authenticationType" @@ -117,51 +156,43 @@ class Direction: Desc = "desc" Asc = "asc" - def __init__(self, pagenumber=1, pagesize=None): - self.pagenumber = pagenumber - self.pagesize = pagesize or config.PAGE_SIZE - self.sort = set() - self.filter = set() - # This is private until we expand all of our parsers to handle the extra fields - self._all_fields = False - - def page_size(self, page_size): - self.pagesize = page_size - return self +""" +These options can be used by methods that are fetching data exported from a specific content item +""" - def page_number(self, page_number): - self.pagenumber = page_number - return self - - def get_query_params(self): - params = {} - if self.pagenumber: - params["pageNumber"] = self.pagenumber - if self.pagesize: - params["pageSize"] = self.pagesize - if len(self.sort) > 0: - sort_options = (str(sort_item) for sort_item in self.sort) - ordered_sort_options = sorted(sort_options) - params["sort"] = ",".join(ordered_sort_options) - if len(self.filter) > 0: - filter_options = (str(filter_item) for filter_item in self.filter) - ordered_filter_options = sorted(filter_options) - params["filter"] = ",".join(ordered_filter_options) - if self._all_fields: - params["fields"] = "_all_" - return params +class _DataExportOptions(RequestOptionsBase): + def __init__(self, maxage: int = -1): + super().__init__() + self.view_filters: list[tuple[str, str]] = [] + self.view_parameters: list[tuple[str, str]] = [] + self.max_age: Optional[int] = maxage + """ + This setting will affect the contents of the workbook as they are exported. + Valid language values are tableau-supported languages like de, es, en + If no locale is specified, the default locale for that language will be used + """ + self.language: Optional[str] = None -class _FilterOptionsBase(RequestOptionsBase): - """Provide a basic implementation of adding view filters to the url""" + @property + def max_age(self) -> int: + return self._max_age - def __init__(self): - self.view_filters = [] - self.view_parameters = [] + @max_age.setter + @property_is_int(range=(0, 240), allowed=[-1]) + def max_age(self, value): + self._max_age = value def get_query_params(self): - raise NotImplementedError() + params = {} + if self.max_age != -1: + params["maxAge"] = self.max_age + if self.language: + params["language"] = self.language + + self._append_view_filters(params) + return params def vf(self, name: str, value: str) -> Self: """Apply a filter based on a column within the view. @@ -182,82 +213,33 @@ def _append_view_filters(self, params) -> None: params[name] = value -class CSVRequestOptions(_FilterOptionsBase): - def __init__(self, maxage=-1): - super().__init__() - self.max_age = maxage - - @property - def max_age(self): - return self._max_age +class CSVRequestOptions(_DataExportOptions): + extension = "csv" - @max_age.setter - @property_is_int(range=(0, 240), allowed=[-1]) - def max_age(self, value): - self._max_age = value - - def get_query_params(self): - params = {} - if self.max_age != -1: - params["maxAge"] = self.max_age - - self._append_view_filters(params) - return params - - -class ExcelRequestOptions(_FilterOptionsBase): - def __init__(self, maxage: int = -1) -> None: - super().__init__() - self.max_age = maxage - - @property - def max_age(self) -> int: - return self._max_age - - @max_age.setter - @property_is_int(range=(0, 240), allowed=[-1]) - def max_age(self, value: int) -> None: - self._max_age = value - def get_query_params(self): - params = {} - if self.max_age != -1: - params["maxAge"] = self.max_age +class ExcelRequestOptions(_DataExportOptions): + extension = "xlsx" - self._append_view_filters(params) - return params +class ImageRequestOptions(_DataExportOptions): + extension = "png" -class ImageRequestOptions(_FilterOptionsBase): # if 'high' isn't specified, the REST API endpoint returns an image with standard resolution class Resolution: High = "high" def __init__(self, imageresolution=None, maxage=-1): - super().__init__() + super().__init__(maxage=maxage) self.image_resolution = imageresolution - self.max_age = maxage - - @property - def max_age(self): - return self._max_age - - @max_age.setter - @property_is_int(range=(0, 240), allowed=[-1]) - def max_age(self, value): - self._max_age = value def get_query_params(self): - params = {} + params = super().get_query_params() if self.image_resolution: params["resolution"] = self.image_resolution - if self.max_age != -1: - params["maxAge"] = self.max_age - self._append_view_filters(params) return params -class PDFRequestOptions(_FilterOptionsBase): +class PDFRequestOptions(_DataExportOptions): class PageType: A3 = "a3" A4 = "a4" @@ -279,22 +261,12 @@ class Orientation: Landscape = "landscape" def __init__(self, page_type=None, orientation=None, maxage=-1, viz_height=None, viz_width=None): - super().__init__() + super().__init__(maxage=maxage) 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): - return self._max_age - - @max_age.setter - @property_is_int(range=(0, 240), allowed=[-1]) - def max_age(self, value): - self._max_age = value - @property def viz_height(self): return self._viz_height @@ -313,17 +285,14 @@ def viz_width(self): def viz_width(self, value): self._viz_width = value - def get_query_params(self): - params = {} + def get_query_params(self) -> dict: + params = super().get_query_params() if self.page_type: params["type"] = self.page_type if self.orientation: params["orientation"] = self.orientation - 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") @@ -334,6 +303,4 @@ def get_query_params(self): if self.viz_width is not None: params["vizWidth"] = self.viz_width - self._append_view_filters(params) - return params diff --git a/test/test_request_option.py b/test/test_request_option.py index 9ca9779ad..7405189a3 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -358,3 +358,13 @@ def test_queryset_pagesize_filter(self) -> None: queryset = self.server.views.all().filter(page_size=page_size) assert queryset.request_options.pagesize == page_size _ = list(queryset) + + def test_language_export(self) -> None: + with requests_mock.mock() as m: + m.get(requests_mock.ANY) + url = self.baseurl + "/views/456/data" + opts = TSC.PDFRequestOptions() + opts.language = "en-US" + + resp = self.server.users.get_request(url, request_object=opts) + self.assertTrue(re.search("language=en-us", resp.request.query)) From 1b64987b4eb63f5adee21c5c4eb0038d6045e15f Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 11 Oct 2024 16:02:32 -0500 Subject: [PATCH 246/296] docs: docstrings for user item and endpoint (#1485) * docs: docstrings for user item and endpoint * docs: add serverresponseerror details --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/user_item.py | 30 ++ .../server/endpoint/users_endpoint.py | 347 ++++++++++++++++++ 2 files changed, 377 insertions(+) diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index fb29492e4..365e44c1d 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -19,9 +19,34 @@ class UserItem: + """ + The UserItem class contains the members or attributes for the view + resources on Tableau Server. The UserItem class defines the information you + can request or query from Tableau Server. The class attributes correspond + to the attributes of a server request or response payload. + + + Parameters + ---------- + name: str + The name of the user. + + site_role: str + The role of the user on the site. + + auth_setting: str + Required attribute for Tableau Cloud. How the user autenticates to the + server. + """ + tag_name: str = "user" class Roles: + """ + The Roles class contains the possible roles for a user on Tableau + Server. + """ + Interactor = "Interactor" Publisher = "Publisher" ServerAdministrator = "ServerAdministrator" @@ -43,6 +68,11 @@ class Roles: SupportUser = "SupportUser" class Auth: + """ + The Auth class contains the possible authentication settings for a user + on Tableau Cloud. + """ + OpenID = "OpenID" SAML = "SAML" TableauIDWithMFA = "TableauIDWithMFA" diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 793638396..d81907ae9 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -14,6 +14,14 @@ class Users(QuerysetEndpoint[UserItem]): + """ + The user resources for Tableau Server are defined in the UserItem class. + The class corresponds to the user resources you can access using the + Tableau Server REST API. The user methods are based upon the endpoints for + users in the REST API and operate on the UserItem class. Only server and + site administrators can access the user resources. + """ + @property def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/users" @@ -21,6 +29,60 @@ def baseurl(self) -> str: # Gets all users @api(version="2.0") def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[UserItem], PaginationItem]: + """ + Query all users on the site. Request is paginated and returns a subset of users. + By default, the request returns the first 100 users on the site. + + Parameters + ---------- + req_options : Optional[RequestOptions] + Optional request options to filter and sort the results. + + Returns + ------- + tuple[list[UserItem], PaginationItem] + Returns a tuple with a list of UserItem objects and a PaginationItem object. + + Raises + ------ + ServerResponseError + code: 400006 + summary: Invalid page number + detail: The page number is not an integer, is less than one, or is + greater than the final page number for users at the requested + page size. + + ServerResponseError + code: 400007 + summary: Invalid page size + detail: The page size parameter is not an integer, is less than one. + + ServerResponseError + code: 403014 + summary: Page size limit exceeded + detail: The specified page size is larger than the maximum page size + + ServerResponseError + code: 404000 + summary: Site not found + detail: The site ID in the URI doesn't correspond to an existing site. + + ServerResponseError + code: 405000 + summary: Invalid request method + detail: Request type was not GET. + + Examples + -------- + >>> import tableauserverclient as TSC + >>> tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') + >>> server = TSC.Server('https://SERVERURL') + + >>> with server.auth.sign_in(tableau_auth): + >>> users_page, pagination_item = server.users.get() + >>> print("\nThere are {} user on site: ".format(pagination_item.total_available)) + >>> print([user.name for user in users_page]) + """ logger.info("Querying all users on site") if req_options is None: @@ -36,6 +98,49 @@ def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[UserIt # Gets 1 user by id @api(version="2.0") def get_by_id(self, user_id: str) -> UserItem: + """ + Query a single user by ID. + + Parameters + ---------- + user_id : str + The ID of the user to query. + + Returns + ------- + UserItem + The user item that was queried. + + Raises + ------ + ValueError + If the user ID is not specified. + + ServerResponseError + code: 404000 + summary: Site not found + detail: The site ID in the URI doesn't correspond to an existing site. + + ServerResponseError + code: 403133 + summary: Query user permissions forbidden + detail: The user does not have permissions to query user information + for other users + + ServerResponseError + code: 404002 + summary: User not found + detail: The user ID in the URI doesn't correspond to an existing user. + + ServerResponseError + code: 405000 + summary: Invalid request method + detail: Request type was not GET. + + Examples + -------- + >>> user1 = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') + """ if not user_id: error = "User ID undefined." raise ValueError(error) @@ -47,6 +152,47 @@ def get_by_id(self, user_id: str) -> UserItem: # Update user @api(version="2.0") def update(self, user_item: UserItem, password: Optional[str] = None) -> UserItem: + """ + Modifies information about the specified user. + + If Tableau Server is configured to use local authentication, you can + update the user's name, email address, password, or site role. + + If Tableau Server is configured to use Active Directory + authentication, you can change the user's display name (full name), + email address, and site role. However, if you synchronize the user with + Active Directory, the display name and email address will be + overwritten with the information that's in Active Directory. + + For Tableau Cloud, you can update the site role for a user, but you + cannot update or change a user's password, user name (email address), + or full name. + + Parameters + ---------- + user_item : UserItem + The user item to update. + + password : Optional[str] + The new password for the user. + + Returns + ------- + UserItem + The user item that was updated. + + Raises + ------ + MissingRequiredFieldError + If the user item is missing an ID. + + Examples + -------- + >>> user = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') + >>> user.fullname = 'New Full Name' + >>> updated_user = server.users.update(user) + + """ if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) @@ -61,6 +207,31 @@ def update(self, user_item: UserItem, password: Optional[str] = None) -> UserIte # Delete 1 user by id @api(version="2.0") def remove(self, user_id: str, map_assets_to: Optional[str] = None) -> None: + """ + Removes a user from the site. You can also specify a user to map the + assets to when you remove the user. + + Parameters + ---------- + user_id : str + The ID of the user to remove. + + map_assets_to : Optional[str] + The ID of the user to map the assets to when you remove the user. + + Returns + ------- + None + + Raises + ------ + ValueError + If the user ID is not specified. + + Examples + -------- + >>> server.users.remove('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') + """ if not user_id: error = "User ID undefined." raise ValueError(error) @@ -73,6 +244,95 @@ def remove(self, user_id: str, map_assets_to: Optional[str] = None) -> None: # Add new user to site @api(version="2.0") def add(self, user_item: UserItem) -> UserItem: + """ + Adds the user to the site. + + To add a new user to the site you need to first create a new user_item + (from UserItem class). When you create a new user, you specify the name + of the user and their site role. For Tableau Cloud, you also specify + the auth_setting attribute in your request. When you add user to + Tableau Cloud, the name of the user must be the email address that is + used to sign in to Tableau Cloud. After you add a user, Tableau Cloud + sends the user an email invitation. The user can click the link in the + invitation to sign in and update their full name and password. + + Parameters + ---------- + user_item : UserItem + The user item to add to the site. + + Returns + ------- + UserItem + The user item that was added to the site with attributes from the + site populated. + + Raises + ------ + ValueError + If the user item is missing a name + + ValueError + If the user item is missing a site role + + ServerResponseError + code: 400000 + summary: Bad Request + detail: The content of the request body is missing or incomplete, or + contains malformed XML. + + ServerResponseError + code: 400003 + summary: Bad Request + detail: The user authentication setting ServerDefault is not + supported for you site. Try again using TableauIDWithMFA instead. + + ServerResponseError + code: 400013 + summary: Invalid site role + detail: The value of the siteRole attribute must be Explorer, + ExplorerCanPublish, SiteAdministratorCreator, + SiteAdministratorExplorer, Unlicensed, or Viewer. + + ServerResponseError + code: 404000 + summary: Site not found + detail: The site ID in the URI doesn't correspond to an existing site. + + ServerResponseError + code: 404002 + summary: User not found + detail: The server is configured to use Active Directory for + authentication, and the username specified in the request body + doesn't match an existing user in Active Directory. + + ServerResponseError + code: 405000 + summary: Invalid request method + detail: Request type was not POST. + + ServerResponseError + code: 409000 + summary: User conflict + detail: The specified user already exists on the site. + + ServerResponseError + code: 409005 + summary: Guest user conflict + detail: The Tableau Server API doesn't allow adding a user with the + guest role to a site. + + + Examples + -------- + >>> import tableauserverclient as TSC + >>> server = TSC.Server('https://SERVERURL') + >>> # Login to the server + + >>> new_user = TSC.UserItem(name='new_user', site_role=TSC.UserItem.Role.Unlicensed) + >>> new_user = server.users.add(new_user) + + """ url = self.baseurl logger.info(f"Add user {user_item.name}") add_req = RequestFactory.User.add_req(user_item) @@ -122,6 +382,42 @@ def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[Us # Get workbooks for user @api(version="2.0") def populate_workbooks(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: + """ + Returns information about the workbooks that the specified user owns + and has Read (view) permissions for. + + This method retrieves the workbook information for the specified user. + The REST API is designed to return only the information you ask for + explicitly. When you query for all the users, the workbook information + for each user is not included. Use this method to retrieve information + about the workbooks that the user owns or has Read (view) permissions. + The method adds the list of workbooks to the user item object + (user_item.workbooks). + + Parameters + ---------- + user_item : UserItem + The user item to populate workbooks for. + + req_options : Optional[RequestOptions] + Optional request options to filter and sort the results. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the user item is missing an ID. + + Examples + -------- + >>> user = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') + >>> server.users.populate_workbooks(user) + >>> for wb in user.workbooks: + >>> print(wb.name) + """ if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) @@ -142,11 +438,62 @@ def _get_wbs_for_user( return workbook_item, pagination_item def populate_favorites(self, user_item: UserItem) -> None: + """ + Populate the favorites for the user. + + Parameters + ---------- + user_item : UserItem + The user item to populate favorites for. + + Returns + ------- + None + + Examples + -------- + >>> import tableauserverclient as TSC + >>> server = TSC.Server('https://SERVERURL') + >>> # Login to the server + + >>> user = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') + >>> server.users.populate_favorites(user) + >>> for obj_type, items in user.favorites.items(): + >>> print(f"Favorites for {obj_type}:") + >>> for item in items: + >>> print(item.name) + """ self.parent_srv.favorites.get(user_item) # Get groups for user @api(version="3.7") def populate_groups(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: + """ + Populate the groups for the user. + + Parameters + ---------- + user_item : UserItem + The user item to populate groups for. + + req_options : Optional[RequestOptions] + Optional request options to filter and sort the results. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the user item is missing an ID. + + Examples + -------- + >>> server.users.populate_groups(user) + >>> for group in user.groups: + >>> print(group.name) + """ if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) From 9b1b9406df55e1eee0baba7b52d5586a3854b83a Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 14 Oct 2024 11:53:12 -0500 Subject: [PATCH 247/296] ci: build on python 3.13 (#1492) Now that python 3.13 has released, test builds on actual 3.13 instead of the 3.13-dev build Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- .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 7e1533eef..ac622795a 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -13,7 +13,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13-dev'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] runs-on: ${{ matrix.os }} From d880d520ee1b8d5d94e3d5a9caa896286fe2a9dd Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 15 Oct 2024 02:04:22 -0500 Subject: [PATCH 248/296] docs: workbook docstrings (#1488) Add detailed docstrings to workbook item and endpoint Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/workbook_item.py | 78 +++ .../server/endpoint/workbooks_endpoint.py | 598 +++++++++++++++++- 2 files changed, 674 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index ab5ff4157..776d041e3 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -21,6 +21,84 @@ class WorkbookItem: + """ + The workbook resources for Tableau are defined in the WorkbookItem class. + The class corresponds to the workbook resources you can access using the + Tableau REST API. Some workbook methods take an instance of the WorkbookItem + class as arguments. The workbook item specifies the project. + + Parameters + ---------- + project_id : Optional[str], optional + The project ID for the workbook, by default None. + + name : Optional[str], optional + The name of the workbook, by default None. + + show_tabs : bool, optional + Determines whether the workbook shows tabs for the view. + + Attributes + ---------- + connections : list[ConnectionItem] + The list of data connections (ConnectionItem) for the data sources used + by the workbook. You must first call the workbooks.populate_connections + method to access this data. See the ConnectionItem class. + + content_url : Optional[str] + The name of the workbook as it appears in the URL. + + created_at : Optional[datetime.datetime] + The date and time the workbook was created. + + description : Optional[str] + User-defined description of the workbook. + + id : Optional[str] + The identifier for the workbook. You need this value to query a specific + workbook or to delete a workbook with the get_by_id and delete methods. + + owner_id : Optional[str] + The identifier for the owner (UserItem) of the workbook. + + preview_image : bytes + The thumbnail image for the view. You must first call the + workbooks.populate_preview_image method to access this data. + + project_name : Optional[str] + The name of the project that contains the workbook. + + size: int + The size of the workbook in megabytes. + + hidden_views: Optional[list[str]] + List of string names of views that need to be hidden when the workbook + is published. + + tags: set[str] + The set of tags associated with the workbook. + + updated_at : Optional[datetime.datetime] + The date and time the workbook was last updated. + + views : list[ViewItem] + The list of views (ViewItem) for the workbook. You must first call the + workbooks.populate_views method to access this data. See the ViewItem + class. + + web_page_url : Optional[str] + The full URL for the workbook. + + Examples + -------- + # creating a new instance of a WorkbookItem + >>> import tableauserverclient as TSC + + >>> # Create new workbook_item with project id '3a8b6148-493c-11e6-a621-6f3499394a39' + + >>> new_workbook = TSC.WorkbookItem('3a8b6148-493c-11e6-a621-6f3499394a39') + """ + def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, show_tabs: bool = False) -> None: self._connections = None self._content_url = None diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 5e4442b60..460017d1a 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -7,6 +7,7 @@ from pathlib import Path from tableauserverclient.helpers.headers import fix_filename +from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.server.query import QuerySet from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api, parameter_added_in @@ -69,6 +70,22 @@ def baseurl(self) -> str: # Get all workbooks on site @api(version="2.0") def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[WorkbookItem], PaginationItem]: + """ + Queries the server and returns information about the workbooks the site. + + Parameters + ---------- + req_options : RequestOptions, optional + (Optional) You can pass the method a request object that contains + additional parameters to filter the request. For example, if you + were searching for a specific workbook, you could specify the name + of the workbook or the name of the owner. + + Returns + ------- + Tuple containing one page's worth of workbook items and pagination + information. + """ logger.info("Querying all workbooks on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -79,6 +96,19 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Work # Get 1 workbook @api(version="2.0") def get_by_id(self, workbook_id: str) -> WorkbookItem: + """ + Returns information about the specified workbook on the site. + + Parameters + ---------- + workbook_id : str + The workbook ID. + + Returns + ------- + WorkbookItem + The workbook item. + """ if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) @@ -89,6 +119,19 @@ def get_by_id(self, workbook_id: str) -> WorkbookItem: @api(version="2.8") def refresh(self, workbook_item: Union[WorkbookItem, str]) -> JobItem: + """ + Refreshes the extract of an existing workbook. + + Parameters + ---------- + workbook_item : WorkbookItem | str + The workbook item or workbook ID. + + Returns + ------- + JobItem + The job item. + """ id_ = getattr(workbook_item, "id", workbook_item) url = f"{self.baseurl}/{id_}/refresh" empty_req = RequestFactory.Empty.empty_req() @@ -105,6 +148,33 @@ def create_extract( includeAll: bool = True, datasources: Optional[list["DatasourceItem"]] = None, ) -> JobItem: + """ + Create one or more extracts on 1 workbook, optionally encrypted. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#create_extracts_for_workbook + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to create extracts for. + + encrypt : bool, default False + Set to True to encrypt the extracts. + + includeAll : bool, default True + If True, all data sources in the workbook will have an extract + created for them. If False, then a data source must be supplied in + the request. + + datasources : list[DatasourceItem] | None + List of DatasourceItem objects for the data sources to create + extracts for. Only required if includeAll is False. + + Returns + ------- + JobItem + The job item for the extract creation. + """ id_ = getattr(workbook_item, "id", workbook_item) url = f"{self.baseurl}/{id_}/createExtract?encrypt={encrypt}" @@ -116,6 +186,29 @@ def create_extract( # delete all the extracts on 1 workbook @api(version="3.3") def delete_extract(self, workbook_item: WorkbookItem, includeAll: bool = True, datasources=None) -> JobItem: + """ + Delete all extracts of embedded datasources on 1 workbook. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#delete_extracts_from_workbook + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to delete extracts from. + + includeAll : bool, default True + If True, all data sources in the workbook will have their extracts + deleted. If False, then a data source must be supplied in the + request. + + datasources : list[DatasourceItem] | None + List of DatasourceItem objects for the data sources to delete + extracts from. Only required if includeAll is False. + + Returns + ------- + JobItem + """ id_ = getattr(workbook_item, "id", workbook_item) url = f"{self.baseurl}/{id_}/deleteExtract" datasource_req = RequestFactory.Workbook.embedded_extract_req(includeAll, datasources) @@ -126,6 +219,18 @@ def delete_extract(self, workbook_item: WorkbookItem, includeAll: bool = True, d # Delete 1 workbook by id @api(version="2.0") def delete(self, workbook_id: str) -> None: + """ + Deletes a workbook with the specified ID. + + Parameters + ---------- + workbook_id : str + The workbook ID. + + Returns + ------- + None + """ if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) @@ -141,6 +246,29 @@ def update( workbook_item: WorkbookItem, include_view_acceleration_status: bool = False, ) -> WorkbookItem: + """ + Modifies an existing workbook. Use this method to change the owner or + the project that the workbook belongs to, or to change whether the + workbook shows views in tabs. The workbook item must include the + workbook ID and overrides the existing settings. + + See https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#update_workbook + for a list of fields that can be updated. + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to update. ID is required. Other fields are + optional. Any fields that are not specified will not be changed. + + include_view_acceleration_status : bool, default False + Set to True to include the view acceleration status in the response. + + Returns + ------- + WorkbookItem + The updated workbook item. + """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -161,6 +289,28 @@ def update( # Update workbook_connection @api(version="2.3") def update_connection(self, workbook_item: WorkbookItem, connection_item: ConnectionItem) -> ConnectionItem: + """ + Updates a workbook connection information (server addres, server port, + user name, and password). + + The workbook connections must be populated before the strings can be + updated. + + Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#update_workbook_connection + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to update. + + connection_item : ConnectionItem + The connection item to update. + + Returns + ------- + ConnectionItem + The updated connection item. + """ url = f"{self.baseurl}/{workbook_item.id}/connections/{connection_item.id}" update_req = RequestFactory.Connection.update_req(connection_item) server_response = self.put_request(url, update_req) @@ -179,6 +329,34 @@ def download( filepath: Optional[PathOrFileW] = None, include_extract: bool = True, ) -> PathOrFileW: + """ + Downloads a workbook to the specified directory (optional). + + Parameters + ---------- + workbook_id : str + The workbook ID. + + filepath : Path or File object, optional + Downloads the file to the location you specify. If no location is + specified, the file is downloaded to the current working directory. + The default is Filepath=None. + + include_extract : bool, default True + Set to False to exclude the extract from the download. The default + is True. + + Returns + ------- + Path or File object + The path to the downloaded workbook or the file object. + + Raises + ------ + ValueError + If the workbook ID is not defined. + """ + return self.download_revision( workbook_id, None, @@ -189,6 +367,36 @@ def download( # Get all views of workbook @api(version="2.0") def populate_views(self, workbook_item: WorkbookItem, usage: bool = False) -> None: + """ + Populates (or gets) a list of views for a workbook. + + You must first call this method to populate views before you can iterate + through the views. + + This method retrieves the view information for the specified workbook. + The REST API is designed to return only the information you ask for + explicitly. When you query for all the workbooks, the view information + is not included. Use this method to retrieve the views. The method adds + the list of views to the workbook item (workbook_item.views). This is a + list of ViewItem. + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate views for. + + usage : bool, default False + Set to True to include usage statistics for each view. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -214,6 +422,36 @@ def _get_views_for_workbook(self, workbook_item: WorkbookItem, usage: bool) -> l # Get all connections of workbook @api(version="2.0") def populate_connections(self, workbook_item: WorkbookItem) -> None: + """ + Populates a list of data source connections for the specified workbook. + + You must populate connections before you can iterate through the + connections. + + This method retrieves the data source connection information for the + specified workbook. The REST API is designed to return only the + information you ask for explicitly. When you query all the workbooks, + the data source connection information is not included. Use this method + to retrieve the connection information for any data sources used by the + workbook. The method adds the list of data connections to the workbook + item (workbook_item.connections). This is a list of ConnectionItem. + + REST API docs: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_workbook_connections + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate connections for. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -235,6 +473,34 @@ def _get_workbook_connections( # Get the pdf of the entire workbook if its tabs are enabled, pdf of the default view if its tabs are disabled @api(version="3.4") def populate_pdf(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None) -> None: + """ + Populates the PDF for the specified workbook item. + + This method populates a PDF with image(s) of the workbook view(s) you + specify. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#download_workbook_pdf + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate the PDF for. + + req_options : RequestOptions, optional + (Optional) You can pass in request options to specify the page type + and orientation of the PDF content, as well as the maximum age of + the PDF rendered on the server. See PDFRequestOptions class for more + details. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID." raise MissingRequiredFieldError(error) @@ -253,6 +519,36 @@ def _get_wb_pdf(self, workbook_item: WorkbookItem, req_options: Optional["Reques @api(version="3.8") def populate_powerpoint(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None) -> None: + """ + Populates the PowerPoint for the specified workbook item. + + This method populates a PowerPoint with image(s) of the workbook view(s) you + specify. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#download_workbook_powerpoint + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate the PDF for. + + req_options : RequestOptions, optional + (Optional) You can pass in request options to specify the maximum + number of minutes a workbook .pptx will be cached before being + refreshed. To prevent multiple .pptx requests from overloading the + server, the shortest interval you can set is one minute. There is no + maximum value, but the server job enacting the caching action may + expire before a long cache period is reached. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID." raise MissingRequiredFieldError(error) @@ -272,6 +568,26 @@ def _get_wb_pptx(self, workbook_item: WorkbookItem, req_options: Optional["Reque # Get preview image of workbook @api(version="2.0") def populate_preview_image(self, workbook_item: WorkbookItem) -> None: + """ + This method gets the preview image (thumbnail) for the specified workbook item. + + This method uses the workbook's ID to get the preview image. The method + adds the preview image to the workbook item (workbook_item.preview_image). + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate the preview image for. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -290,14 +606,65 @@ def _get_wb_preview_image(self, workbook_item: WorkbookItem) -> bytes: @api(version="2.0") def populate_permissions(self, item: WorkbookItem) -> None: + """ + Populates the permissions for the specified workbook item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_workbook_permissions + + Parameters + ---------- + item : WorkbookItem + The workbook item to populate permissions for. + + Returns + ------- + None + """ self._permissions.populate(item) @api(version="2.0") - def update_permissions(self, resource, rules): + def update_permissions(self, resource: WorkbookItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: + """ + Updates the permissions for the specified workbook item. The method + replaces the existing permissions with the new permissions. Any missing + permissions are removed. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_permissions_for_content + + Parameters + ---------- + resource : WorkbookItem + The workbook item to update permissions for. + + rules : list[PermissionsRule] + A list of permissions rules to apply to the workbook item. + + Returns + ------- + list[PermissionsRule] + The updated permissions rules. + """ return self._permissions.update(resource, rules) @api(version="2.0") - def delete_permission(self, item, capability_item): + def delete_permission(self, item: WorkbookItem, capability_item: PermissionsRule) -> None: + """ + Deletes a single permission rule from the specified workbook item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_workbook_permission + + Parameters + ---------- + item : WorkbookItem + The workbook item to delete the permission from. + + capability_item : PermissionsRule + The permission rule to delete. + + Returns + ------- + None + """ return self._permissions.delete(item, capability_item) @api(version="2.0") @@ -313,6 +680,83 @@ def publish( skip_connection_check: bool = False, parameters=None, ): + """ + Publish a workbook to the specified site. + + Note: The REST API cannot automatically include extracts or other + resources that the workbook uses. Therefore, a .twb file that uses data + from an Excel or csv file on a local computer cannot be published, + unless you package the data and workbook in a .twbx file, or publish the + data source separately. + + For workbooks that are larger than 64 MB, the publish method + automatically takes care of chunking the file in parts for uploading. + Using this method is considerably more convenient than calling the + publish REST APIs directly. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#publish_workbook + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook_item specifies the workbook you are publishing. When + you are adding a workbook, you need to first create a new instance + of a workbook_item that includes a project_id of an existing + project. The name of the workbook will be the name of the file, + unless you also specify a name for the new workbook when you create + the instance. + + file : Path or File object + The file path or file object of the workbook to publish. When + providing a file object, you must also specifiy the name of the + workbook in your instance of the workbook_itemworkbook_item , as + the name cannot be derived from the file name. + + mode : str + Specifies whether you are publishing a new workbook (CreateNew) or + overwriting an existing workbook (Overwrite). You cannot appending + workbooks. You can also use the publish mode attributes, for + example: TSC.Server.PublishMode.Overwrite. + + connections : list[ConnectionItem] | None + List of ConnectionItems objects for the connections created within + the workbook. + + as_job : bool, default False + Set to True to run the upload as a job (asynchronous upload). If set + to True a job will start to perform the publishing process and a Job + object is returned. Defaults to False. + + skip_connection_check : bool, default False + Set to True to skip connection check at time of upload. Publishing + will succeed but unchecked connection issues may result in a + non-functioning workbook. Defaults to False. + + Raises + ------ + OSError + If the file path does not lead to an existing file. + + ServerResponseError + If the server response is not successful. + + TypeError + If the file is not a file path or file object. + + ValueError + If the file extension is not supported + + ValueError + If the mode is invalid. + + ValueError + Workbooks cannot be appended. + + Returns + ------- + WorkbookItem | JobItem + The workbook item or job item that was published. + """ if isinstance(file, (str, os.PathLike)): if not os.path.isfile(file): error = "File path does not lead to an existing file." @@ -419,6 +863,28 @@ def publish( # Populate workbook item's revisions @api(version="2.3") def populate_revisions(self, workbook_item: WorkbookItem) -> None: + """ + Populates (or gets) a list of revisions for a workbook. + + You must first call this method to populate revisions before you can + iterate through the revisions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#get_workbook_revisions + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate revisions for. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -446,6 +912,40 @@ def download_revision( filepath: Optional[PathOrFileW] = None, include_extract: bool = True, ) -> PathOrFileW: + """ + Downloads a workbook revision to the specified directory (optional). + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#download_workbook_revision + + Parameters + ---------- + workbook_id : str + The workbook ID. + + revision_number : str | None + The revision number of the workbook. If None, the latest revision is + downloaded. + + filepath : Path or File object, optional + Downloads the file to the location you specify. If no location is + specified, the file is downloaded to the current working directory. + The default is Filepath=None. + + include_extract : bool, default True + Set to False to exclude the extract from the download. The default + is True. + + Returns + ------- + Path or File object + The path to the downloaded workbook or the file object. + + Raises + ------ + ValueError + If the workbook ID is not defined. + """ + if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) @@ -479,6 +979,28 @@ def download_revision( @api(version="2.3") def delete_revision(self, workbook_id: str, revision_number: str) -> None: + """ + Deletes a specific revision from a workbook on Tableau Server. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_revisions.htm#remove_workbook_revision + + Parameters + ---------- + workbook_id : str + The workbook ID. + + revision_number : str + The revision number of the workbook to delete. + + Returns + ------- + None + + Raises + ------ + ValueError + If the workbook ID or revision number is not defined. + """ if workbook_id is None or revision_number is None: raise ValueError url = "/".join([self.baseurl, workbook_id, "revisions", revision_number]) @@ -491,18 +1013,90 @@ def delete_revision(self, workbook_id: str, revision_number: str) -> None: def schedule_extract_refresh( self, schedule_id: str, item: WorkbookItem ) -> list["AddResponse"]: # actually should return a task + """ + Adds a workbook to a schedule for extract refresh. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#add_workbook_to_schedule + + Parameters + ---------- + schedule_id : str + The schedule ID. + + item : WorkbookItem + The workbook item to add to the schedule. + + Returns + ------- + list[AddResponse] + The response from the server. + """ return self.parent_srv.schedules.add_to_schedule(schedule_id, workbook=item) @api(version="1.0") def add_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> set[str]: + """ + Adds tags to a workbook. One or more tags may be added at a time. If a + tag already exists on the workbook, it will not be duplicated. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#add_tags_to_workbook + + Parameters + ---------- + item : WorkbookItem | str + The workbook item or workbook ID to add tags to. + + tags : Iterable[str] | str + The tag or tags to add to the workbook. Tags can be a single tag or + a list of tags. + + Returns + ------- + set[str] + The set of tags added to the workbook. + """ return super().add_tags(item, tags) @api(version="1.0") def delete_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> None: + """ + Deletes tags from a workbook. One or more tags may be deleted at a time. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#delete_tag_from_workbook + + Parameters + ---------- + item : WorkbookItem | str + The workbook item or workbook ID to delete tags from. + + tags : Iterable[str] | str + The tag or tags to delete from the workbook. Tags can be a single + tag or a list of tags. + + Returns + ------- + None + """ return super().delete_tags(item, tags) @api(version="1.0") def update_tags(self, item: WorkbookItem) -> None: + """ + Updates the tags on a workbook. This method is used to update the tags + on the server to match the tags on the workbook item. This method is a + convenience method that calls add_tags and delete_tags to update the + tags on the server. + + Parameters + ---------- + item : WorkbookItem + The workbook item to update the tags for. The tags on the workbook + item will be used to update the tags on the server. + + Returns + ------- + None + """ return super().update_tags(item) def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[WorkbookItem]: From 9f59af159436a8f6b0da2f4155d8a9f4d37d2766 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 15 Oct 2024 02:05:50 -0500 Subject: [PATCH 249/296] chore: type hint default permissions endpoints (#1493) Resource is not currently an actual type, but an enum-like holder for literal values. Added a Union for str types to make mypy happy. Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/tableau_types.py | 2 +- .../endpoint/default_permissions_endpoint.py | 10 ++- .../server/endpoint/projects_endpoint.py | 73 +++++++++++-------- 3 files changed, 51 insertions(+), 34 deletions(-) diff --git a/tableauserverclient/models/tableau_types.py b/tableauserverclient/models/tableau_types.py index ea2a5e4f8..01ee3d3a9 100644 --- a/tableauserverclient/models/tableau_types.py +++ b/tableauserverclient/models/tableau_types.py @@ -28,7 +28,7 @@ class Resource: TableauItem = Union[DatasourceItem, FlowItem, MetricItem, ProjectItem, ViewItem, WorkbookItem, VirtualConnectionItem] -def plural_type(content_type: Resource) -> str: +def plural_type(content_type: Union[Resource, str]) -> str: if content_type == Resource.Lens: return "lenses" else: diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py index 343d8b097..499324e8e 100644 --- a/tableauserverclient/server/endpoint/default_permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py @@ -39,7 +39,7 @@ def __str__(self): __repr__ = __str__ def update_default_permissions( - self, resource: BaseItem, permissions: Sequence[PermissionsRule], content_type: Resource + self, resource: BaseItem, permissions: Sequence[PermissionsRule], content_type: Union[Resource, str] ) -> list[PermissionsRule]: url = f"{self.owner_baseurl()}/{resource.id}/default-permissions/{plural_type(content_type)}" update_req = RequestFactory.Permission.add_req(permissions) @@ -50,7 +50,9 @@ def update_default_permissions( return permissions - def delete_default_permission(self, resource: BaseItem, rule: PermissionsRule, content_type: Resource) -> None: + def delete_default_permission( + self, resource: BaseItem, rule: PermissionsRule, content_type: Union[Resource, str] + ) -> None: for capability, mode in rule.capabilities.items(): # Made readability better but line is too long, will make this look better url = ( @@ -72,7 +74,7 @@ def delete_default_permission(self, resource: BaseItem, rule: PermissionsRule, c logger.info(f"Deleted permission for {rule.grantee.tag_name} {rule.grantee.id} item {resource.id}") - def populate_default_permissions(self, item: BaseItem, content_type: Resource) -> None: + def populate_default_permissions(self, item: BaseItem, content_type: Union[Resource, str]) -> None: if not item.id: error = "Server item is missing ID. Item must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -84,7 +86,7 @@ def permission_fetcher() -> list[PermissionsRule]: logger.info(f"Populated default {content_type} permissions for item (ID: {item.id})") def _get_default_permissions( - self, item: BaseItem, content_type: Resource, req_options: Optional["RequestOptions"] = None + self, item: BaseItem, content_type: Union[Resource, str], req_options: Optional["RequestOptions"] = None ) -> list[PermissionsRule]: url = f"{self.owner_baseurl()}/{item.id}/default-permissions/{plural_type(content_type)}" server_response = self.get_request(url, req_options) diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 773b942de..74bb865c7 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -5,6 +5,7 @@ from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint from tableauserverclient.server import RequestFactory, RequestOptions +from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.models import ProjectItem, PaginationItem, Resource from typing import Optional, TYPE_CHECKING @@ -78,119 +79,133 @@ def populate_permissions(self, item: ProjectItem) -> None: self._permissions.populate(item) @api(version="2.0") - def update_permissions(self, item, rules): + def update_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: return self._permissions.update(item, rules) @api(version="2.0") - def delete_permission(self, item, rules): + def delete_permission(self, item: ProjectItem, rules: list[PermissionsRule]) -> None: self._permissions.delete(item, rules) @api(version="2.1") - def populate_workbook_default_permissions(self, item): + def populate_workbook_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.Workbook) @api(version="2.1") - def populate_datasource_default_permissions(self, item): + def populate_datasource_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.Datasource) @api(version="3.2") - def populate_metric_default_permissions(self, item): + def populate_metric_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.Metric) @api(version="3.4") - def populate_datarole_default_permissions(self, item): + def populate_datarole_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.Datarole) @api(version="3.4") - def populate_flow_default_permissions(self, item): + def populate_flow_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.Flow) @api(version="3.4") - def populate_lens_default_permissions(self, item): + def populate_lens_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.Lens) @api(version="3.23") - def populate_virtualconnection_default_permissions(self, item): + def populate_virtualconnection_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.VirtualConnection) @api(version="3.23") - def populate_database_default_permissions(self, item): + def populate_database_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.Database) @api(version="3.23") - def populate_table_default_permissions(self, item): + def populate_table_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.Table) @api(version="2.1") - def update_workbook_default_permissions(self, item, rules): + def update_workbook_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.Workbook) @api(version="2.1") - def update_datasource_default_permissions(self, item, rules): + def update_datasource_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.Datasource) @api(version="3.2") - def update_metric_default_permissions(self, item, rules): + def update_metric_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.Metric) @api(version="3.4") - def update_datarole_default_permissions(self, item, rules): + def update_datarole_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.Datarole) @api(version="3.4") - def update_flow_default_permissions(self, item, rules): + def update_flow_default_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.Flow) @api(version="3.4") - def update_lens_default_permissions(self, item, rules): + def update_lens_default_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.Lens) @api(version="3.23") - def update_virtualconnection_default_permissions(self, item, rules): + def update_virtualconnection_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.VirtualConnection) @api(version="3.23") - def update_database_default_permissions(self, item, rules): + def update_database_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.Database) @api(version="3.23") - def update_table_default_permissions(self, item, rules): + def update_table_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.Table) @api(version="2.1") - def delete_workbook_default_permissions(self, item, rule): + def delete_workbook_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.Workbook) @api(version="2.1") - def delete_datasource_default_permissions(self, item, rule): + def delete_datasource_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.Datasource) @api(version="3.2") - def delete_metric_default_permissions(self, item, rule): + def delete_metric_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.Metric) @api(version="3.4") - def delete_datarole_default_permissions(self, item, rule): + def delete_datarole_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.Datarole) @api(version="3.4") - def delete_flow_default_permissions(self, item, rule): + def delete_flow_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.Flow) @api(version="3.4") - def delete_lens_default_permissions(self, item, rule): + def delete_lens_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.Lens) @api(version="3.23") - def delete_virtualconnection_default_permissions(self, item, rule): + def delete_virtualconnection_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.VirtualConnection) @api(version="3.23") - def delete_database_default_permissions(self, item, rule): + def delete_database_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.Database) @api(version="3.23") - def delete_table_default_permissions(self, item, rule): + def delete_table_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.Table) def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[ProjectItem]: From 2ff96971ef7be58850121cca398111cc7810cec5 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 17 Oct 2024 00:00:58 -0500 Subject: [PATCH 250/296] fix: handle 0 item response in querysets (#1501) * fix: handle 0 item response in querysets A flaw in the __iter__ logic introduced to handle scenarios where a pagination element is not included in the response xml resulted in an infinite loop. This PR introduces a few changes to protect against this: 1. After running QuerySet._fetch_all(), if the result_cache is empty, return instead of performing other comparisons. 2. Ensure that any non-None total_available is returned from the PaginationItem's object. 3. In _fetch_all, check if there is a PaginationItem that has been populated so as to not call the server side endpoint muliple times before returning. * fix: null out PaginationItem._page_number Tests were failing because the fetch_all method added a second check before fetching the next page. This fix will allow the next page to be retrieved when used normally --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/server/query.py | 8 ++++++-- test/test_pager.py | 12 ++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index feebc1a7e..801ad4a13 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -77,6 +77,7 @@ def __iter__(self: Self) -> Iterator[T]: for page in count(1): self.request_options.pagenumber = page self._result_cache = [] + self._pagination_item._page_number = None try: self._fetch_all() except ServerResponseError as e: @@ -85,6 +86,8 @@ def __iter__(self: Self) -> Iterator[T]: # up overrunning the total number of pages. Catch the # error and break out of the loop. raise StopIteration + if len(self._result_cache) == 0: + return yield from self._result_cache # If the length of the QuerySet is unknown, continue fetching until # the result cache is empty. @@ -139,6 +142,7 @@ def __getitem__(self, k): elif k in range(self.total_available): # Otherwise, check if k is even sensible to return self._result_cache = [] + self._pagination_item._page_number = None # 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] @@ -150,7 +154,7 @@ def _fetch_all(self: Self) -> None: """ Retrieve the data and store result and pagination item in cache """ - if not self._result_cache: + if not self._result_cache and self._pagination_item._page_number is None: response = self.model.get(self.request_options) if isinstance(response, tuple): self._result_cache, self._pagination_item = response @@ -159,7 +163,7 @@ def _fetch_all(self: Self) -> None: self._pagination_item = PaginationItem() def __len__(self: Self) -> int: - return self.total_available or sys.maxsize + return sys.maxsize if self.total_available is None else self.total_available @property def total_available(self: Self) -> int: diff --git a/test/test_pager.py b/test/test_pager.py index c30352809..1836095bb 100644 --- a/test/test_pager.py +++ b/test/test_pager.py @@ -1,6 +1,7 @@ import contextlib import os import unittest +import xml.etree.ElementTree as ET import requests_mock @@ -122,3 +123,14 @@ def test_pager_view(self) -> None: m.get(self.server.views.baseurl, text=view_xml) for view in TSC.Pager(self.server.views): assert view.name is not None + + def test_queryset_no_matches(self) -> None: + elem = ET.Element("tsResponse", xmlns="http://tableau.com/api") + ET.SubElement(elem, "pagination", totalAvailable="0") + ET.SubElement(elem, "groups") + xml = ET.tostring(elem).decode("utf-8") + with requests_mock.mock() as m: + m.get(self.server.groups.baseurl, text=xml) + all_groups = self.server.groups.all() + groups = list(all_groups) + assert len(groups) == 0 From e623511f67f9952d252716e3792808760552cd67 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 17 Oct 2024 00:39:08 -0500 Subject: [PATCH 251/296] ci: cache dependencies for faster builds (#1497) * ci: cache dependencies for faster builds * ci: cache for mypy and black --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- .github/workflows/meta-checks.yml | 14 ++++++++++++++ .github/workflows/run-tests.yml | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/.github/workflows/meta-checks.yml b/.github/workflows/meta-checks.yml index 41a944e63..0e2b425ee 100644 --- a/.github/workflows/meta-checks.yml +++ b/.github/workflows/meta-checks.yml @@ -13,6 +13,20 @@ jobs: runs-on: ${{ matrix.os }} steps: + - name: Get pip cache dir + id: pip-cache + shell: bash + run: | + echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT + + - name: cache + uses: actions/cache@v4 + with: + path: ${{ steps.pip-cache.outputs.dir }} + key: ${{ runner.os }}-${{ matrix.python-version }}-pip-${{ hashFiles('pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.python-version }}-pip- + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index ac622795a..2e197cf20 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -18,6 +18,20 @@ jobs: runs-on: ${{ matrix.os }} steps: + - name: Get pip cache dir + id: pip-cache + shell: bash + run: | + echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT + + - name: cache + uses: actions/cache@v4 + with: + path: ${{ steps.pip-cache.outputs.dir }} + key: ${{ runner.os }}-${{ matrix.python-version }}-pip-${{ hashFiles('pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.python-version }}-pip- + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} From c361f8f7e0dc57da3dc6542addc843db237c506a Mon Sep 17 00:00:00 2001 From: renoyjohnm <168143499+renoyjohnm@users.noreply.github.com> Date: Tue, 22 Oct 2024 11:33:06 -0700 Subject: [PATCH 252/296] Feature: export custom views #999 (#1506) Adding custom views PDF & CSV export endpoints --- samples/export.py | 5 ++ .../models/custom_view_item.py | 25 +++++- .../server/endpoint/custom_views_endpoint.py | 52 +++++++++++- tableauserverclient/server/request_options.py | 80 +++++++++++-------- test/test_custom_view.py | 72 +++++++++++++++++ 5 files changed, 194 insertions(+), 40 deletions(-) diff --git a/samples/export.py b/samples/export.py index e33710468..b2506cf46 100644 --- a/samples/export.py +++ b/samples/export.py @@ -41,6 +41,7 @@ def main(): "--language", help="Text such as 'Average' will appear in this language. Use values like fr, de, es, en" ) parser.add_argument("--workbook", action="store_true") + parser.add_argument("--custom_view", action="store_true") parser.add_argument("--file", "-f", help="filename to store the exported data") parser.add_argument("--filter", "-vf", metavar="COLUMN:VALUE", help="View filter to apply to the view") @@ -58,6 +59,8 @@ def main(): print("Connected") if args.workbook: item = server.workbooks.get_by_id(args.resource_id) + elif args.custom_view: + item = server.custom_views.get_by_id(args.resource_id) else: item = server.views.get_by_id(args.resource_id) @@ -74,6 +77,8 @@ def main(): populate = getattr(server.views, populate_func_name) if args.workbook: populate = getattr(server.workbooks, populate_func_name) + elif args.custom_view: + populate = getattr(server.custom_views, populate_func_name) option_factory = getattr(TSC, option_factory_name) options: TSC.PDFRequestOptions = option_factory() diff --git a/tableauserverclient/models/custom_view_item.py b/tableauserverclient/models/custom_view_item.py index de917bf4a..a0c0a9844 100644 --- a/tableauserverclient/models/custom_view_item.py +++ b/tableauserverclient/models/custom_view_item.py @@ -3,6 +3,7 @@ from defusedxml import ElementTree from defusedxml.ElementTree import fromstring, tostring from typing import Callable, Optional +from collections.abc import Iterator from .exceptions import UnpopulatedPropertyError from .user_item import UserItem @@ -17,6 +18,8 @@ def __init__(self, id: Optional[str] = None, name: Optional[str] = None) -> None self._created_at: Optional["datetime"] = None self._id: Optional[str] = id self._image: Optional[Callable[[], bytes]] = None + self._pdf: Optional[Callable[[], bytes]] = None + self._csv: Optional[Callable[[], Iterator[bytes]]] = None self._name: Optional[str] = name self._shared: Optional[bool] = False self._updated_at: Optional["datetime"] = None @@ -40,6 +43,12 @@ def __repr__(self: "CustomViewItem"): def _set_image(self, image): self._image = image + def _set_pdf(self, pdf): + self._pdf = pdf + + def _set_csv(self, csv): + self._csv = csv + @property def content_url(self) -> Optional[str]: return self._content_url @@ -55,10 +64,24 @@ def id(self) -> Optional[str]: @property def image(self) -> bytes: if self._image is None: - error = "View item must be populated with its png image first." + error = "Custom View item must be populated with its png image first." raise UnpopulatedPropertyError(error) return self._image() + @property + def pdf(self) -> bytes: + if self._pdf is None: + error = "Custom View item must be populated with its pdf first." + raise UnpopulatedPropertyError(error) + return self._pdf() + + @property + def csv(self) -> Iterator[bytes]: + if self._csv is None: + error = "Custom View item must be populated with its csv first." + raise UnpopulatedPropertyError(error) + return self._csv() + @property def name(self) -> Optional[str]: return self._name diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index 63899ba0c..b02b05d78 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -1,15 +1,23 @@ import io import logging import os +from contextlib import closing from pathlib import Path from typing import Optional, Union +from collections.abc import Iterator from tableauserverclient.config import BYTES_PER_MB, config from tableauserverclient.filesys_helpers import get_file_object_size from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError from tableauserverclient.models import CustomViewItem, PaginationItem -from tableauserverclient.server import RequestFactory, RequestOptions, ImageRequestOptions +from tableauserverclient.server import ( + RequestFactory, + RequestOptions, + ImageRequestOptions, + PDFRequestOptions, + CSVRequestOptions, +) from tableauserverclient.helpers.logging import logger @@ -91,9 +99,45 @@ def _get_view_image(self, view_item: CustomViewItem, req_options: Optional["Imag image = server_response.content return image - """ - Not yet implemented: pdf or csv exports - """ + @api(version="3.23") + def populate_pdf(self, custom_view_item: CustomViewItem, req_options: Optional["PDFRequestOptions"] = None) -> None: + if not custom_view_item.id: + error = "Custom View item missing ID." + raise MissingRequiredFieldError(error) + + def pdf_fetcher(): + return self._get_custom_view_pdf(custom_view_item, req_options) + + custom_view_item._set_pdf(pdf_fetcher) + logger.info(f"Populated pdf for custom view (ID: {custom_view_item.id})") + + def _get_custom_view_pdf( + self, custom_view_item: CustomViewItem, req_options: Optional["PDFRequestOptions"] + ) -> bytes: + url = f"{self.baseurl}/{custom_view_item.id}/pdf" + server_response = self.get_request(url, req_options) + pdf = server_response.content + return pdf + + @api(version="3.23") + def populate_csv(self, custom_view_item: CustomViewItem, req_options: Optional["CSVRequestOptions"] = None) -> None: + if not custom_view_item.id: + error = "Custom View item missing ID." + raise MissingRequiredFieldError(error) + + def csv_fetcher(): + return self._get_custom_view_csv(custom_view_item, req_options) + + custom_view_item._set_csv(csv_fetcher) + logger.info(f"Populated csv for custom view (ID: {custom_view_item.id})") + + def _get_custom_view_csv( + self, custom_view_item: CustomViewItem, req_options: Optional["CSVRequestOptions"] + ) -> Iterator[bytes]: + url = f"{self.baseurl}/{custom_view_item.id}/data" + + with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: + yield from server_response.iter_content(1024) @api(version="3.18") def update(self, view_item: CustomViewItem) -> Optional[CustomViewItem]: diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 0d47abfcc..d79ac7f73 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -213,6 +213,46 @@ def _append_view_filters(self, params) -> None: params[name] = value +class _ImagePDFCommonExportOptions(_DataExportOptions): + def __init__(self, maxage=-1, viz_height=None, viz_width=None): + super().__init__(maxage=maxage) + self.viz_height = viz_height + self.viz_width = viz_width + + @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) -> dict: + params = super().get_query_params() + + # 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") + + if self.viz_height is not None: + params["vizHeight"] = self.viz_height + + if self.viz_width is not None: + params["vizWidth"] = self.viz_width + + return params + + class CSVRequestOptions(_DataExportOptions): extension = "csv" @@ -221,15 +261,15 @@ class ExcelRequestOptions(_DataExportOptions): extension = "xlsx" -class ImageRequestOptions(_DataExportOptions): +class ImageRequestOptions(_ImagePDFCommonExportOptions): extension = "png" # if 'high' isn't specified, the REST API endpoint returns an image with standard resolution class Resolution: High = "high" - def __init__(self, imageresolution=None, maxage=-1): - super().__init__(maxage=maxage) + def __init__(self, imageresolution=None, maxage=-1, viz_height=None, viz_width=None): + super().__init__(maxage=maxage, viz_height=viz_height, viz_width=viz_width) self.image_resolution = imageresolution def get_query_params(self): @@ -239,7 +279,7 @@ def get_query_params(self): return params -class PDFRequestOptions(_DataExportOptions): +class PDFRequestOptions(_ImagePDFCommonExportOptions): class PageType: A3 = "a3" A4 = "a4" @@ -261,29 +301,9 @@ class Orientation: Landscape = "landscape" def __init__(self, page_type=None, orientation=None, maxage=-1, viz_height=None, viz_width=None): - super().__init__(maxage=maxage) + super().__init__(maxage=maxage, viz_height=viz_height, viz_width=viz_width) self.page_type = page_type self.orientation = orientation - self.viz_height = viz_height - self.viz_width = viz_width - - @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) -> dict: params = super().get_query_params() @@ -293,14 +313,4 @@ def get_query_params(self) -> dict: if self.orientation: params["orientation"] = self.orientation - # 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") - - if self.viz_height is not None: - params["vizHeight"] = self.viz_height - - if self.viz_width is not None: - params["vizWidth"] = self.viz_width - return params diff --git a/test/test_custom_view.py b/test/test_custom_view.py index 80800c86b..6e863a863 100644 --- a/test/test_custom_view.py +++ b/test/test_custom_view.py @@ -18,6 +18,8 @@ GET_XML_ID = os.path.join(TEST_ASSET_DIR, "custom_view_get_id.xml") POPULATE_PREVIEW_IMAGE = os.path.join(TEST_ASSET_DIR, "Sample View Image.png") CUSTOM_VIEW_UPDATE_XML = os.path.join(TEST_ASSET_DIR, "custom_view_update.xml") +CUSTOM_VIEW_POPULATE_PDF = os.path.join(TEST_ASSET_DIR, "populate_pdf.pdf") +CUSTOM_VIEW_POPULATE_CSV = os.path.join(TEST_ASSET_DIR, "populate_csv.csv") CUSTOM_VIEW_DOWNLOAD = TEST_ASSET_DIR / "custom_view_download.json" FILE_UPLOAD_INIT = TEST_ASSET_DIR / "fileupload_initialize.xml" FILE_UPLOAD_APPEND = TEST_ASSET_DIR / "fileupload_append.xml" @@ -246,3 +248,73 @@ def test_large_publish(self): assert isinstance(view, TSC.CustomViewItem) assert view.id is not None assert view.name is not None + + def test_populate_pdf(self) -> None: + self.server.version = "3.23" + self.baseurl = self.server.custom_views.baseurl + with open(CUSTOM_VIEW_POPULATE_PDF, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get( + self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/pdf?type=letter&orientation=portrait&maxAge=5", + content=response, + ) + custom_view = TSC.CustomViewItem() + custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + + size = TSC.PDFRequestOptions.PageType.Letter + orientation = TSC.PDFRequestOptions.Orientation.Portrait + req_option = TSC.PDFRequestOptions(size, orientation, 5) + + self.server.custom_views.populate_pdf(custom_view, req_option) + self.assertEqual(response, custom_view.pdf) + + def test_populate_csv(self) -> None: + self.server.version = "3.23" + self.baseurl = self.server.custom_views.baseurl + with open(CUSTOM_VIEW_POPULATE_CSV, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/data?maxAge=1", content=response) + custom_view = TSC.CustomViewItem() + custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + request_option = TSC.CSVRequestOptions(maxage=1) + self.server.custom_views.populate_csv(custom_view, request_option) + + csv_file = b"".join(custom_view.csv) + self.assertEqual(response, csv_file) + + def test_populate_csv_default_maxage(self) -> None: + self.server.version = "3.23" + self.baseurl = self.server.custom_views.baseurl + with open(CUSTOM_VIEW_POPULATE_CSV, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/data", content=response) + custom_view = TSC.CustomViewItem() + custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + self.server.custom_views.populate_csv(custom_view) + + csv_file = b"".join(custom_view.csv) + self.assertEqual(response, csv_file) + + def test_pdf_height(self) -> None: + self.server.version = "3.23" + self.baseurl = self.server.custom_views.baseurl + with open(CUSTOM_VIEW_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, + ) + custom_view = TSC.CustomViewItem() + custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + + req_option = TSC.PDFRequestOptions( + viz_height=1080, + viz_width=1920, + ) + + self.server.custom_views.populate_pdf(custom_view, req_option) + self.assertEqual(response, custom_view.pdf) From 607fa8b6b1ead27bb128c9dc0d97a1c0e53b1955 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 22 Oct 2024 15:59:16 -0500 Subject: [PATCH 253/296] chore: remove py2 holdover code (#1496) Favor list comprehensions for readability, consistency, and performance Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/server/endpoint/schedules_endpoint.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index 4ed243b25..eec4536f9 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -115,8 +115,7 @@ def add_to_schedule( ) # type:ignore[arg-type] results = (self._add_to(*x) for x in items) - # list() is needed for python 3.x compatibility - return list(filter(lambda x: not x.result, results)) # type:ignore[arg-type] + return [x for x in results if not x.result] def _add_to( self, From 60dfd4d293920cffeb194ae6e3e27ac5bea83694 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Tue, 22 Oct 2024 16:38:46 -0700 Subject: [PATCH 254/296] Update samples for Python 3.x compatibility (#1479) * Replace obsolete env package with os.environ * Python 2.x to 3.x updates * Fix some comments * Remove workbook data acceleration; feature was removed in 2022 * Remove switch_site() example which is confusing in this context of demonstrating login --- samples/extracts.py | 12 +- samples/login.py | 21 ++-- samples/publish_datasource.py | 23 ++-- samples/set_refresh_schedule.py | 2 +- samples/update_connection.py | 2 +- samples/update_workbook_data_acceleration.py | 109 ------------------- 6 files changed, 32 insertions(+), 137 deletions(-) delete mode 100644 samples/update_workbook_data_acceleration.py diff --git a/samples/extracts.py b/samples/extracts.py index d21bfdd0b..c0dd885bc 100644 --- a/samples/extracts.py +++ b/samples/extracts.py @@ -1,13 +1,7 @@ #### -# This script demonstrates how to use the Tableau Server Client -# to interact with workbooks. It explores the different -# functions that the Server API supports on workbooks. -# -# With no flags set, this sample will query all workbooks, -# pick one workbook and populate its connections/views, and update -# the workbook. Adding flags will demonstrate the specific feature -# on top of the general operations. -#### +# This script demonstrates how to use the Tableau Server Client to interact with extracts. +# It explores the different functions that the REST API supports on extracts. +##### import argparse import logging diff --git a/samples/login.py b/samples/login.py index 847d3558f..bc99385b3 100644 --- a/samples/login.py +++ b/samples/login.py @@ -7,9 +7,15 @@ import argparse import getpass import logging +import os import tableauserverclient as TSC -import env + + +def get_env(key): + if key in os.environ: + return os.environ[key] + return None # If a sample has additional arguments, then it should copy this code and insert them after the call to @@ -20,13 +26,13 @@ def set_up_and_log_in(): sample_define_common_options(parser) args = parser.parse_args() if not args.server: - args.server = env.server + args.server = get_env("SERVER") if not args.site: - args.site = env.site + args.site = get_env("SITE") if not args.token_name: - args.token_name = env.token_name + args.token_name = get_env("TOKEN_NAME") if not args.token_value: - args.token_value = env.token_value + args.token_value = get_env("TOKEN_VALUE") args.logging_level = "debug" server = sample_connect_to_server(args) @@ -79,10 +85,7 @@ def sample_connect_to_server(args): # Make sure we use an updated version of the rest apis, and pass in our cert handling choice server = TSC.Server(args.server, use_server_version=True, http_options={"verify": check_ssl_certificate}) server.auth.sign_in(tableau_auth) - server.version = "2.6" - new_site: TSC.SiteItem = TSC.SiteItem("cdnear", content_url=env.site) - server.auth.switch_site(new_site) - print("Logged in successfully") + server.version = "3.19" return server diff --git a/samples/publish_datasource.py b/samples/publish_datasource.py index 85f63fb35..c674e6882 100644 --- a/samples/publish_datasource.py +++ b/samples/publish_datasource.py @@ -21,12 +21,17 @@ import argparse import logging +import os import tableauserverclient as TSC - -import env import tableauserverclient.datetime_helpers +def get_env(key): + if key in os.environ: + return os.environ[key] + return None + + def main(): parser = argparse.ArgumentParser(description="Publish a datasource to server.") # Common options; please keep those in sync across all samples @@ -52,13 +57,13 @@ def main(): args = parser.parse_args() if not args.server: - args.server = env.server + args.server = get_env("SERVER") if not args.site: - args.site = env.site + args.site = get_env("SITE") if not args.token_name: - args.token_name = env.token_name + args.token_name = get_env("TOKEN_NAME") if not args.token_value: - args.token_value = env.token_value + args.token_value = get_env("TOKEN_VALUE") args.logging = "debug" args.file = "C:/dev/tab-samples/5M.tdsx" args.async_ = True @@ -118,8 +123,10 @@ def main(): new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds ) print( - "{}Datasource published. Datasource ID: {}".format( - new_datasource.id, tableauserverclient.datetime_helpers.timestamp() + ( + "{}Datasource published. Datasource ID: {}".format( + new_datasource.id, tableauserverclient.datetime_helpers.timestamp() + ) ) ) print("\t\tClosing connection") diff --git a/samples/set_refresh_schedule.py b/samples/set_refresh_schedule.py index 56fd12e62..153bb0ee5 100644 --- a/samples/set_refresh_schedule.py +++ b/samples/set_refresh_schedule.py @@ -38,7 +38,7 @@ def usage(args): def make_filter(**kwargs): options = TSC.RequestOptions() - for item, value in kwargs.items(): + for item, value in list(kwargs.items()): name = getattr(TSC.RequestOptions.Field, item) options.filter.add(TSC.Filter(name, TSC.RequestOptions.Operator.Equals, value)) return options diff --git a/samples/update_connection.py b/samples/update_connection.py index 4af6592bc..0fe2f342c 100644 --- a/samples/update_connection.py +++ b/samples/update_connection.py @@ -45,7 +45,7 @@ def main(): update_function = endpoint.update_connection resource = endpoint.get_by_id(args.resource_id) endpoint.populate_connections(resource) - connections = list(filter(lambda x: x.id == args.connection_id, resource.connections)) + connections = list([x for x in resource.connections if x.id == args.connection_id]) assert len(connections) == 1 connection = connections[0] connection.username = args.datasource_username diff --git a/samples/update_workbook_data_acceleration.py b/samples/update_workbook_data_acceleration.py deleted file mode 100644 index 57a1363ed..000000000 --- a/samples/update_workbook_data_acceleration.py +++ /dev/null @@ -1,109 +0,0 @@ -#### -# 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(f"\nThere are {pagination_item.total_available} workbooks on site: ") - 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() From 63ece8235a1febcb58edd881f3fa91bac52836f2 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 22 Oct 2024 18:54:42 -0500 Subject: [PATCH 255/296] chore: support VizqlDataApiAccess capability (#1504) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/permissions_item.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 186cebedd..bb3487279 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -36,6 +36,7 @@ class Capability: ShareView = "ShareView" ViewComments = "ViewComments" ViewUnderlyingData = "ViewUnderlyingData" + VizqlDataApiAccess = "VizqlDataApiAccess" WebAuthoring = "WebAuthoring" Write = "Write" RunExplainData = "RunExplainData" From b65d8d4ab5a05d575676c5034bc40efffe425f3d Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 22 Oct 2024 17:07:31 -0700 Subject: [PATCH 256/296] Remove sample code showing group name encoding (#1486) * Remove sample code showing group name encoding This is no longer needed - ran the sample and verified that it works now. --- samples/filter_sort_groups.py | 44 ++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/samples/filter_sort_groups.py b/samples/filter_sort_groups.py index d967659ad..1694bf0f5 100644 --- a/samples/filter_sort_groups.py +++ b/samples/filter_sort_groups.py @@ -47,7 +47,7 @@ def main(): 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=True) + server = TSC.Server(args.server, use_server_version=True, http_options={"verify": False}) with server.auth.sign_in(tableau_auth): group_name = "SALES NORTHWEST" # Try to create a group named "SALES NORTHWEST" @@ -57,37 +57,36 @@ def main(): # Try to create a group named "SALES ROMANIA" create_example_group(group_name, server) - # URL Encode the name of the group that we want to filter on - # i.e. turn spaces into plus signs - filter_group_name = urllib.parse.quote_plus(group_name) + # we no longer need to encode the space options = TSC.RequestOptions() - options.filter.add( - TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, filter_group_name) - ) + options.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, group_name)) filtered_groups, _ = server.groups.get(req_options=options) # Result can either be a matching group or an empty list if filtered_groups: - group_name = filtered_groups.pop().name - print(group_name) + group = filtered_groups.pop() + print(group) else: - error = f"No project named '{filter_group_name}' found" + error = f"No group named '{group_name}' found" print(error) + print("---") + # Or, try the above with the django style filtering try: - group = server.groups.filter(name=filter_group_name)[0] + group = server.groups.filter(name=group_name)[0] + print(group) except IndexError: - print(f"No project named '{filter_group_name}' found") - else: - print(group.name) + print(f"No group named '{group_name}' found") + + print("====") options = TSC.RequestOptions() options.filter.add( TSC.Filter( TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.In, - ["SALES+NORTHWEST", "SALES+ROMANIA", "this_group"], + ["SALES NORTHWEST", "SALES ROMANIA", "this_group"], ) ) @@ -98,13 +97,20 @@ def main(): for group in matching_groups: print(group.name) + print("----") # or, try the above with the django style filtering. - - groups = ["SALES NORTHWEST", "SALES ROMANIA", "this_group"] - groups = [urllib.parse.quote_plus(group) for group in groups] - for group in server.groups.filter(name__in=groups).sort("-name"): + all_g = server.groups.all() + print(f"Searching locally among {all_g.total_available} groups") + for a in all_g: + print(a) + groups = [urllib.parse.quote_plus(group) for group in ["SALES NORTHWEST", "SALES ROMANIA", "this_group"]] + print(groups) + + for group in server.groups.filter(name__in=groups).order_by("-name"): print(group.name) + print("done") + if __name__ == "__main__": main() From 3e3837267bd1c2d24d3d49d1ec017755d264ed00 Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 23 Oct 2024 10:19:08 -0700 Subject: [PATCH 257/296] Update requests library for CVE CVE-2024-35195 (#1507) Update pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 67faefbe1..08f90c49c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ readme = "README.md" dependencies = [ '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 + 'requests>=2.32', # latest as at 7/31/23 'urllib3>=2.2.2,<3', 'typing_extensions>=4.0.1', ] From 878d5934759939a9bd79689c2b8c3c7a1cee024f Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Wed, 23 Oct 2024 12:19:27 -0500 Subject: [PATCH 258/296] docs: docstrings for site item and endpoint (#1495) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/site_item.py | 66 +++++ .../server/endpoint/sites_endpoint.py | 265 ++++++++++++++++++ 2 files changed, 331 insertions(+) diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index 2d9f014a2..e4e146f9c 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -21,6 +21,72 @@ class SiteItem: + """ + The SiteItem class contains the members or attributes for the site resources + on Tableau Server or Tableau Cloud. The SiteItem class defines the + information you can request or query from Tableau Server or Tableau Cloud. + The class members correspond to the attributes of a server request or + response payload. + + Attributes + ---------- + name: str + The name of the site. The name of the default site is "". + + content_url: str + The path to the site. + + admin_mode: str + (Optional) For Tableau Server only. Specify ContentAndUsers to allow + site administrators to use the server interface and tabcmd commands to + add and remove users. (Specifying this option does not give site + administrators permissions to manage users using the REST API.) Specify + ContentOnly to prevent site administrators from adding or removing + users. (Server administrators can always add or remove users.) + + user_quota: int + (Optional) Specifies the total number of users for the site. The number + can't exceed the number of licenses activated for the site; and if + tiered capacity attributes are set, then user_quota will equal the sum + of the tiered capacity values, and attempting to set user_quota will + cause an error. + + tier_explorer_capacity: int + tier_creator_capacity: int + tier_viewer_capacity: int + (Optional) The maximum number of licenses for users with the Creator, + Explorer, or Viewer role, respectively, allowed on a site. + + storage_quota: int + (Optional) Specifies the maximum amount of space for the new site, in + megabytes. If you set a quota and the site exceeds it, publishers will + be prevented from uploading new content until the site is under the + limit again. + + disable_subscriptions: bool + (Optional) Specify true to prevent users from being able to subscribe + to workbooks on the specified site. The default is False. + + subscribe_others_enabled: bool + (Optional) Specify false to prevent server administrators, site + administrators, and project or content owners from being able to + subscribe other users to workbooks on the specified site. The default + is True. + + revision_history_enabled: bool + (Optional) Specify true to enable revision history for content resources + (workbooks and datasources). The default is False. + + revision_limit: int + (Optional) Specifies the number of revisions of a content source + (workbook or data source) to allow. On Tableau Server, the default is + 25. + + state: str + Shows the current state of the site (Active or Suspended). + + """ + _user_quota: Optional[int] = None _tier_creator_capacity: Optional[int] = None _tier_explorer_capacity: Optional[int] = None diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index 0f3d25908..55d2a5ad0 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -15,6 +15,16 @@ class Sites(Endpoint): + """ + Using the site methods of the Tableau Server REST API you can: + + List sites on a server or get details of a specific site + Create, update, or delete a site + List views in a site + Encrypt, decrypt, or reencrypt extracts on a site + + """ + @property def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites" @@ -22,6 +32,25 @@ def baseurl(self) -> str: # Gets all sites @api(version="2.0") def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[SiteItem], PaginationItem]: + """ + Query all sites on the server. This method requires server admin + permissions. This endpoint is paginated, meaning that the server will + only return a subset of the data at a time. The response will contain + information about the total number of sites and the number of sites + returned in the current response. Use the PaginationItem object to + request more data. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_sites + + Parameters + ---------- + req_options : RequestOptions, optional + Filtering options for the request. + + Returns + ------- + tuple[list[SiteItem], PaginationItem] + """ logger.info("Querying all sites on site") logger.info("Requires Server Admin permissions") url = self.baseurl @@ -33,6 +62,33 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Site # Gets 1 site by id @api(version="2.0") def get_by_id(self, site_id: str) -> SiteItem: + """ + Query a single site on the server. You can only retrieve the site that + you are currently authenticated for. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_site + + Parameters + ---------- + site_id : str + The site ID. + + Returns + ------- + SiteItem + + Raises + ------ + ValueError + If the site ID is not defined. + + ValueError + If the site ID does not match the site for which you are currently authenticated. + + Examples + -------- + >>> site = server.sites.get_by_id('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + """ if not site_id: error = "Site ID undefined." raise ValueError(error) @@ -48,6 +104,31 @@ def get_by_id(self, site_id: str) -> SiteItem: # Gets 1 site by name @api(version="2.0") def get_by_name(self, site_name: str) -> SiteItem: + """ + Query a single site on the server. You can only retrieve the site that + you are currently authenticated for. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_site + + Parameters + ---------- + site_name : str + The site name. + + Returns + ------- + SiteItem + + Raises + ------ + ValueError + If the site name is not defined. + + Examples + -------- + >>> site = server.sites.get_by_name('Tableau') + + """ if not site_name: error = "Site Name undefined." raise ValueError(error) @@ -61,6 +142,31 @@ def get_by_name(self, site_name: str) -> SiteItem: # Gets 1 site by content url @api(version="2.0") def get_by_content_url(self, content_url: str) -> SiteItem: + """ + Query a single site on the server. You can only retrieve the site that + you are currently authenticated for. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_site + + Parameters + ---------- + content_url : str + The content URL. + + Returns + ------- + SiteItem + + Raises + ------ + ValueError + If the site name is not defined. + + Examples + -------- + >>> site = server.sites.get_by_name('Tableau') + + """ if content_url is None: error = "Content URL undefined." raise ValueError(error) @@ -77,6 +183,42 @@ def get_by_content_url(self, content_url: str) -> SiteItem: # Update site @api(version="2.0") def update(self, site_item: SiteItem) -> SiteItem: + """ + Modifies the settings for site. + + The site item object must include the site ID and overrides all other settings. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#update_site + + Parameters + ---------- + site_item : SiteItem + The site item that you want to update. The settings specified in the + site item override the current site settings. + + Returns + ------- + SiteItem + The site item object that was updated. + + Raises + ------ + MissingRequiredFieldError + If the site item is missing an ID. + + ValueError + If the site ID does not match the site for which you are currently authenticated. + + ValueError + If the site admin mode is set to ContentOnly and a user quota is also set. + + Examples + -------- + >>> ... + >>> site_item.name = 'New Name' + >>> updated_site = server.sites.update(site_item) + + """ if not site_item.id: error = "Site item missing ID." raise MissingRequiredFieldError(error) @@ -100,6 +242,29 @@ def update(self, site_item: SiteItem) -> SiteItem: # Delete 1 site object @api(version="2.0") def delete(self, site_id: str) -> None: + """ + Deletes the specified site from the server. You can only delete the site + if you are a Server Admin. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#delete_site + + Parameters + ---------- + site_id : str + The site ID. + + Raises + ------ + ValueError + If the site ID is not defined. + + ValueError + If the site ID does not match the site for which you are currently authenticated. + + Examples + -------- + >>> server.sites.delete('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + """ if not site_id: error = "Site ID undefined." raise ValueError(error) @@ -114,6 +279,47 @@ def delete(self, site_id: str) -> None: # Create new site @api(version="2.0") def create(self, site_item: SiteItem) -> SiteItem: + """ + Creates a new site on the server for the specified site item object. + + Tableau Server only. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#create_site + + Parameters + ---------- + site_item : SiteItem + The settings for the site that you want to create. You need to + create an instance of SiteItem and pass it to the create method. + + Returns + ------- + SiteItem + The site item object that was created. + + Raises + ------ + ValueError + If the site admin mode is set to ContentOnly and a user quota is also set. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> # create an instance of server + >>> server = TSC.Server('https://MY-SERVER') + + >>> # create shortcut for admin mode + >>> content_users=TSC.SiteItem.AdminMode.ContentAndUsers + + >>> # create a new SiteItem + >>> new_site = TSC.SiteItem(name='Tableau', content_url='tableau', admin_mode=content_users, user_quota=15, storage_quota=1000, disable_subscriptions=True) + + >>> # call the sites create method with the SiteItem + >>> new_site = server.sites.create(new_site) + + + """ if site_item.admin_mode: if site_item.admin_mode == SiteItem.AdminMode.ContentOnly and site_item.user_quota: error = "You cannot set admin_mode to ContentOnly and also set a user quota" @@ -128,6 +334,25 @@ def create(self, site_item: SiteItem) -> SiteItem: @api(version="3.5") def encrypt_extracts(self, site_id: str) -> None: + """ + Encrypts all extracts on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#encrypt_extracts + + Parameters + ---------- + site_id : str + The site ID. + + Raises + ------ + ValueError + If the site ID is not defined. + + Examples + -------- + >>> server.sites.encrypt_extracts('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + """ if not site_id: error = "Site ID undefined." raise ValueError(error) @@ -137,6 +362,25 @@ def encrypt_extracts(self, site_id: str) -> None: @api(version="3.5") def decrypt_extracts(self, site_id: str) -> None: + """ + Decrypts all extracts on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#decrypt_extracts + + Parameters + ---------- + site_id : str + The site ID. + + Raises + ------ + ValueError + If the site ID is not defined. + + Examples + -------- + >>> server.sites.decrypt_extracts('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + """ if not site_id: error = "Site ID undefined." raise ValueError(error) @@ -146,6 +390,27 @@ def decrypt_extracts(self, site_id: str) -> None: @api(version="3.5") def re_encrypt_extracts(self, site_id: str) -> None: + """ + Reencrypt all extracts on a site with new encryption keys. If no site is + specified, extracts on the default site will be reencrypted. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#reencrypt_extracts + + Parameters + ---------- + site_id : str + The site ID. + + Raises + ------ + ValueError + If the site ID is not defined. + + Examples + -------- + >>> server.sites.re_encrypt_extracts('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + + """ if not site_id: error = "Site ID undefined." raise ValueError(error) From c3ea910efbe1cfd21c5d773c1dd4faba909269ca Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 23 Oct 2024 11:51:08 -0700 Subject: [PATCH 259/296] Bring development and master into sync (#1509) From 6798b9e5fc27d459fea93b415519331c7adac954 Mon Sep 17 00:00:00 2001 From: renoyjohnm <168143499+renoyjohnm@users.noreply.github.com> Date: Wed, 23 Oct 2024 22:07:23 -0700 Subject: [PATCH 260/296] 0.34 development merge * Feature: export custom views #999 (#1506) * feat(exceptions): separate failed signin error (#1478) * refactor request_options, add language param (#1481) * Set FILESIZE_LIMIT_MB via environment variables (#1466) * added PulseMetricDefine cap (#1490) * Adding project permissions handling for databases, tables and virtual connections (#1482) * fix: queryset support for flowruns (#1460) * fix: set unknown size to sys.maxsize * fix: handle 0 item response in querysets (#1501) * chore: support VizqlDataApiAccess capability (#1504) * refactor(test): extract error factory to _utils * chore(typing): flowruns.cancel can also accept a FlowRunItem * chore: type hint default permissions endpoints (#1493) * chore(versions): update remaining f-strings (#1477) * Make urllib3 dependency more flexible (#1468) * Update requests library for CVE CVE-2024-35195 (#1507) * chore(versions): Upgrade minimum python version (#1465) * ci: cache dependencies for faster builds (#1497) * ci: build on python 3.13 (#1492) * Update samples for Python 3.x compatibility (#1479) * chore: remove py2 holdover code (#1496) * #Add 'description' to datasource sample code (#1475) * Remove sample code showing group name encoding (#1486) * chore(typing): include samples in type checks (#1455) * fix: docstring on QuerySet * docs: add docstrings to auth objects and endpoints (#1484) * docs: docstrings for Server and ServerInfo (#1494) * docs: docstrings for user item and endpoint (#1485) * docs: docstrings for site item and endpoint (#1495) * docs: workbook docstrings (#1488) * #1464 - docs update for filtering on boolean values (#1471) --------- Co-authored-by: Brian Cantoni Co-authored-by: Jordan Woods Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Co-authored-by: Jac Co-authored-by: Henning Merklinger Co-authored-by: AlbertWangXu Co-authored-by: TrimPeachu <77048868+TrimPeachu@users.noreply.github.com> --- .github/workflows/meta-checks.yml | 14 + .github/workflows/run-tests.yml | 16 +- pyproject.toml | 18 +- samples/add_default_permission.py | 4 +- samples/create_group.py | 13 +- samples/create_project.py | 2 +- samples/create_schedules.py | 8 +- samples/explore_datasource.py | 22 +- samples/explore_favorites.py | 16 +- samples/explore_site.py | 2 +- samples/explore_webhooks.py | 4 +- samples/explore_workbook.py | 33 +- samples/export.py | 23 +- samples/extracts.py | 14 +- samples/filter_sort_groups.py | 44 +- samples/filter_sort_projects.py | 2 +- samples/getting_started/1_hello_server.py | 4 +- samples/getting_started/2_hello_site.py | 4 +- samples/getting_started/3_hello_universe.py | 22 +- samples/initialize_server.py | 10 +- samples/list.py | 5 +- samples/login.py | 25 +- samples/move_workbook_sites.py | 8 +- samples/pagination_sample.py | 8 +- samples/publish_datasource.py | 25 +- samples/publish_workbook.py | 4 +- samples/query_permissions.py | 8 +- samples/refresh_tasks.py | 4 +- samples/set_refresh_schedule.py | 2 +- samples/update_connection.py | 2 +- samples/update_workbook_data_acceleration.py | 109 --- .../update_workbook_data_freshness_policy.py | 2 +- tableauserverclient/__init__.py | 50 +- tableauserverclient/_version.py | 18 +- tableauserverclient/config.py | 8 +- tableauserverclient/models/column_item.py | 2 +- .../models/connection_credentials.py | 2 +- tableauserverclient/models/connection_item.py | 12 +- .../models/custom_view_item.py | 35 +- .../models/data_acceleration_report_item.py | 4 +- tableauserverclient/models/data_alert_item.py | 10 +- .../models/data_freshness_policy_item.py | 12 +- tableauserverclient/models/database_item.py | 6 +- tableauserverclient/models/datasource_item.py | 20 +- tableauserverclient/models/dqw_item.py | 2 +- tableauserverclient/models/favorites_item.py | 11 +- tableauserverclient/models/fileupload_item.py | 2 +- tableauserverclient/models/flow_item.py | 12 +- tableauserverclient/models/flow_run_item.py | 6 +- tableauserverclient/models/group_item.py | 8 +- tableauserverclient/models/groupset_item.py | 8 +- tableauserverclient/models/interval_item.py | 18 +- tableauserverclient/models/job_item.py | 16 +- .../models/linked_tasks_item.py | 10 +- tableauserverclient/models/metric_item.py | 10 +- tableauserverclient/models/pagination_item.py | 2 +- .../models/permissions_item.py | 22 +- tableauserverclient/models/project_item.py | 54 +- .../models/property_decorators.py | 23 +- tableauserverclient/models/reference_item.py | 4 +- tableauserverclient/models/revision_item.py | 6 +- tableauserverclient/models/schedule_item.py | 4 +- .../models/server_info_item.py | 32 +- tableauserverclient/models/site_item.py | 72 +- .../models/subscription_item.py | 6 +- tableauserverclient/models/table_item.py | 2 +- tableauserverclient/models/tableau_auth.py | 120 ++- tableauserverclient/models/tableau_types.py | 4 +- tableauserverclient/models/tag_item.py | 7 +- tableauserverclient/models/task_item.py | 8 +- tableauserverclient/models/user_item.py | 64 +- tableauserverclient/models/view_item.py | 21 +- .../models/virtual_connection_item.py | 11 +- tableauserverclient/models/webhook_item.py | 12 +- tableauserverclient/models/workbook_item.py | 102 ++- tableauserverclient/namespace.py | 2 +- tableauserverclient/server/__init__.py | 3 +- .../server/endpoint/auth_endpoint.py | 73 +- .../server/endpoint/custom_views_endpoint.py | 80 +- .../data_acceleration_report_endpoint.py | 4 +- .../server/endpoint/data_alert_endpoint.py | 28 +- .../server/endpoint/databases_endpoint.py | 25 +- .../server/endpoint/datasources_endpoint.py | 103 ++- .../endpoint/default_permissions_endpoint.py | 37 +- .../server/endpoint/dqw_endpoint.py | 18 +- .../server/endpoint/endpoint.py | 40 +- .../server/endpoint/exceptions.py | 30 +- .../server/endpoint/favorites_endpoint.py | 62 +- .../server/endpoint/fileuploads_endpoint.py | 20 +- .../server/endpoint/flow_runs_endpoint.py | 28 +- .../server/endpoint/flow_task_endpoint.py | 4 +- .../server/endpoint/flows_endpoint.py | 59 +- .../server/endpoint/groups_endpoint.py | 35 +- .../server/endpoint/groupsets_endpoint.py | 4 +- .../server/endpoint/jobs_endpoint.py | 14 +- .../server/endpoint/linked_tasks_endpoint.py | 4 +- .../server/endpoint/metadata_endpoint.py | 4 +- .../server/endpoint/metrics_endpoint.py | 20 +- .../server/endpoint/permissions_endpoint.py | 28 +- .../server/endpoint/projects_endpoint.py | 111 ++- .../server/endpoint/resource_tagger.py | 27 +- .../server/endpoint/schedules_endpoint.py | 35 +- .../server/endpoint/server_info_endpoint.py | 45 +- .../server/endpoint/sites_endpoint.py | 299 +++++++- .../server/endpoint/subscriptions_endpoint.py | 20 +- .../server/endpoint/tables_endpoint.py | 29 +- .../server/endpoint/tasks_endpoint.py | 16 +- .../server/endpoint/users_endpoint.py | 385 +++++++++- .../server/endpoint/views_endpoint.py | 37 +- .../endpoint/virtual_connections_endpoint.py | 11 +- .../server/endpoint/webhooks_endpoint.py | 22 +- .../server/endpoint/workbooks_endpoint.py | 708 ++++++++++++++++-- tableauserverclient/server/filter.py | 4 +- tableauserverclient/server/pager.py | 11 +- tableauserverclient/server/query.py | 87 ++- tableauserverclient/server/request_factory.py | 73 +- tableauserverclient/server/request_options.py | 268 +++---- tableauserverclient/server/server.py | 74 +- tableauserverclient/server/sort.py | 4 +- test/_utils.py | 14 + test/assets/flow_runs_get.xml | 3 +- test/assets/server_info_wrong_site.html | 56 ++ test/test_auth.py | 6 +- test/test_custom_view.py | 72 ++ test/test_dataalert.py | 2 +- test/test_datasource.py | 10 +- test/test_endpoint.py | 2 +- test/test_favorites.py | 18 +- test/test_filesys_helpers.py | 2 +- test/test_fileuploads.py | 6 +- test/test_flowruns.py | 23 +- test/test_flowtask.py | 2 +- test/test_group.py | 1 - test/test_job.py | 8 +- test/test_pager.py | 12 + test/test_project.py | 36 +- test/test_regression_tests.py | 6 +- test/test_request_option.py | 24 +- test/test_schedule.py | 18 +- test/test_server_info.py | 10 + test/test_site_model.py | 2 - test/test_tagging.py | 4 +- test/test_task.py | 8 +- test/test_user.py | 7 +- test/test_user_model.py | 9 +- test/test_view.py | 6 +- test/test_view_acceleration.py | 2 +- test/test_workbook.py | 12 +- versioneer.py | 47 +- 149 files changed, 3347 insertions(+), 1407 deletions(-) delete mode 100644 samples/update_workbook_data_acceleration.py create mode 100644 test/assets/server_info_wrong_site.html diff --git a/.github/workflows/meta-checks.yml b/.github/workflows/meta-checks.yml index 41a944e63..0e2b425ee 100644 --- a/.github/workflows/meta-checks.yml +++ b/.github/workflows/meta-checks.yml @@ -13,6 +13,20 @@ jobs: runs-on: ${{ matrix.os }} steps: + - name: Get pip cache dir + id: pip-cache + shell: bash + run: | + echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT + + - name: cache + uses: actions/cache@v4 + with: + path: ${{ steps.pip-cache.outputs.dir }} + key: ${{ runner.os }}-${{ matrix.python-version }}-pip-${{ hashFiles('pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.python-version }}-pip- + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index d70539582..2e197cf20 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -13,11 +13,25 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] runs-on: ${{ matrix.os }} steps: + - name: Get pip cache dir + id: pip-cache + shell: bash + run: | + echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT + + - name: cache + uses: actions/cache@v4 + with: + path: ${{ steps.pip-cache.outputs.dir }} + key: ${{ runner.os }}-${{ matrix.python-version }}-pip-${{ hashFiles('pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.python-version }}-pip- + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} diff --git a/pyproject.toml b/pyproject.toml index 3bf47ea23..08f90c49c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,42 +14,42 @@ readme = "README.md" dependencies = [ '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.2.2', # dependabot + 'requests>=2.32', # latest as at 7/31/23 + 'urllib3>=2.2.2,<3', 'typing_extensions>=4.0.1', ] -requires-python = ">=3.7" +requires-python = ">=3.9" classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12" + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13" ] [project.urls] repository = "https://github.com/tableau/server-client-python" [project.optional-dependencies] -test = ["black==23.7", "build", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", +test = ["black==24.8", "build", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", "requests-mock>=1.0,<2.0"] [tool.black] line-length = 120 -target-version = ['py37', 'py38', 'py39', 'py310', 'py311', 'py312'] +target-version = ['py39', 'py310', 'py311', 'py312', 'py313'] [tool.mypy] check_untyped_defs = false disable_error_code = [ 'misc', - # 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"] +files = ["tableauserverclient", "test", "samples"] show_error_codes = true ignore_missing_imports = true # defusedxml library has no types no_implicit_reexport = true +implicit_optional = true [tool.pytest.ini_options] testpaths = ["test"] diff --git a/samples/add_default_permission.py b/samples/add_default_permission.py index 5a450e8ab..d26d009e2 100644 --- a/samples/add_default_permission.py +++ b/samples/add_default_permission.py @@ -63,10 +63,10 @@ def main(): for permission in new_default_permissions: grantee = permission.grantee capabilities = permission.capabilities - print("\nCapabilities for {0} {1}:".format(grantee.tag_name, grantee.id)) + print(f"\nCapabilities for {grantee.tag_name} {grantee.id}:") for capability in capabilities: - print("\t{0} - {1}".format(capability, capabilities[capability])) + print(f"\t{capability} - {capabilities[capability]}") # Uncomment lines below to DELETE the new capability and the new project # rules_to_delete = TSC.PermissionsRule( diff --git a/samples/create_group.py b/samples/create_group.py index f4c6a9ca9..aca3e895b 100644 --- a/samples/create_group.py +++ b/samples/create_group.py @@ -11,7 +11,6 @@ import os from datetime import time -from typing import List import tableauserverclient as TSC from tableauserverclient import ServerResponseError @@ -63,23 +62,23 @@ def main(): if args.file: filepath = os.path.abspath(args.file) - print("Add users to site from file {}:".format(filepath)) - added: List[TSC.UserItem] - failed: List[TSC.UserItem, TSC.ServerResponseError] + print(f"Add users to site from file {filepath}:") + added: list[TSC.UserItem] + failed: list[TSC.UserItem, TSC.ServerResponseError] added, failed = server.users.create_from_file(filepath) for user, error in failed: print(user, error.code) if error.code == "409017": user = server.users.filter(name=user.name)[0] added.append(user) - print("Adding users to group:{}".format(added)) + print(f"Adding users to group:{added}") for user in added: - print("Adding user {}".format(user)) + print(f"Adding user {user}") try: server.groups.add_user(group, user.id) except ServerResponseError as serverError: if serverError.code == "409011": - print("user {} is already a member of group {}".format(user.name, group.name)) + print(f"user {user.name} is already a member of group {group.name}") else: raise rError diff --git a/samples/create_project.py b/samples/create_project.py index 1fc649f8c..d775902aa 100644 --- a/samples/create_project.py +++ b/samples/create_project.py @@ -84,7 +84,7 @@ def main(): server.projects.populate_datasource_default_permissions(changed_project), server.projects.populate_permissions(changed_project) # Projects have default permissions set for the object types they contain - print("Permissions from project {}:".format(changed_project.id)) + print(f"Permissions from project {changed_project.id}:") print(changed_project.permissions) print( changed_project.default_workbook_permissions, diff --git a/samples/create_schedules.py b/samples/create_schedules.py index dee088571..c23a2eced 100644 --- a/samples/create_schedules.py +++ b/samples/create_schedules.py @@ -55,7 +55,7 @@ def main(): ) try: hourly_schedule = server.schedules.create(hourly_schedule) - print("Hourly schedule created (ID: {}).".format(hourly_schedule.id)) + print(f"Hourly schedule created (ID: {hourly_schedule.id}).") except Exception as e: print(e) @@ -71,7 +71,7 @@ def main(): ) try: daily_schedule = server.schedules.create(daily_schedule) - print("Daily schedule created (ID: {}).".format(daily_schedule.id)) + print(f"Daily schedule created (ID: {daily_schedule.id}).") except Exception as e: print(e) @@ -89,7 +89,7 @@ def main(): ) try: weekly_schedule = server.schedules.create(weekly_schedule) - print("Weekly schedule created (ID: {}).".format(weekly_schedule.id)) + print(f"Weekly schedule created (ID: {weekly_schedule.id}).") except Exception as e: print(e) options = TSC.RequestOptions() @@ -112,7 +112,7 @@ def main(): ) try: monthly_schedule = server.schedules.create(monthly_schedule) - print("Monthly schedule created (ID: {}).".format(monthly_schedule.id)) + print(f"Monthly schedule created (ID: {monthly_schedule.id}).") except Exception as e: print(e) diff --git a/samples/explore_datasource.py b/samples/explore_datasource.py index fb45cb45e..c9f35d5be 100644 --- a/samples/explore_datasource.py +++ b/samples/explore_datasource.py @@ -51,16 +51,17 @@ def main(): if args.publish: if default_project is not None: new_datasource = TSC.DatasourceItem(default_project.id) + new_datasource.description = "Published with a description" new_datasource = server.datasources.publish( new_datasource, args.publish, TSC.Server.PublishMode.Overwrite ) - print("Datasource published. ID: {}".format(new_datasource.id)) + print(f"Datasource published. ID: {new_datasource.id}") else: print("Publish failed. Could not find the default project.") # Gets all datasource items all_datasources, pagination_item = server.datasources.get() - print("\nThere are {} datasources on site: ".format(pagination_item.total_available)) + print(f"\nThere are {pagination_item.total_available} datasources on site: ") print([datasource.name for datasource in all_datasources]) if all_datasources: @@ -69,20 +70,19 @@ def main(): # Populate connections server.datasources.populate_connections(sample_datasource) - print("\nConnections for {}: ".format(sample_datasource.name)) - print( - [ - "{0}({1})".format(connection.id, connection.datasource_name) - for connection in sample_datasource.connections - ] - ) + print(f"\nConnections for {sample_datasource.name}: ") + print([f"{connection.id}({connection.datasource_name})" for connection in sample_datasource.connections]) + + # Demonstrate that description is editable + sample_datasource.description = "Description updated by TSC" + server.datasources.update(sample_datasource) # Add some tags to the datasource original_tag_set = set(sample_datasource.tags) sample_datasource.tags.update("a", "b", "c", "d") server.datasources.update(sample_datasource) - print("\nOld tag set: {}".format(original_tag_set)) - print("New tag set: {}".format(sample_datasource.tags)) + print(f"\nOld tag set: {original_tag_set}") + print(f"New tag set: {sample_datasource.tags}") # Delete all tags that were added by setting tags to original sample_datasource.tags = original_tag_set diff --git a/samples/explore_favorites.py b/samples/explore_favorites.py index 243e91954..f199522ed 100644 --- a/samples/explore_favorites.py +++ b/samples/explore_favorites.py @@ -3,7 +3,7 @@ import argparse import logging import tableauserverclient as TSC -from tableauserverclient import Resource +from tableauserverclient.models import Resource def main(): @@ -39,15 +39,15 @@ def main(): # get all favorites on site for the logged on user user: TSC.UserItem = TSC.UserItem() user.id = server.user_id - print("Favorites for user: {}".format(user.id)) + print(f"Favorites for user: {user.id}") server.favorites.get(user) print(user.favorites) # get list of workbooks all_workbook_items, pagination_item = server.workbooks.get() if all_workbook_items is not None and len(all_workbook_items) > 0: - my_workbook: TSC.WorkbookItem = all_workbook_items[0] - server.favorites.add_favorite(server, user, Resource.Workbook.name(), all_workbook_items[0]) + my_workbook = all_workbook_items[0] + server.favorites.add_favorite(user, Resource.Workbook, all_workbook_items[0]) print( "Workbook added to favorites. Workbook Name: {}, Workbook ID: {}".format( my_workbook.name, my_workbook.id @@ -57,7 +57,7 @@ def main(): if views is not None and len(views) > 0: my_view = views[0] server.favorites.add_favorite_view(user, my_view) - print("View added to favorites. View Name: {}, View ID: {}".format(my_view.name, my_view.id)) + print(f"View added to favorites. View Name: {my_view.name}, View ID: {my_view.id}") all_datasource_items, pagination_item = server.datasources.get() if all_datasource_items: @@ -70,12 +70,10 @@ def main(): ) server.favorites.delete_favorite_workbook(user, my_workbook) - print( - "Workbook deleted from favorites. Workbook Name: {}, Workbook ID: {}".format(my_workbook.name, my_workbook.id) - ) + print(f"Workbook deleted from favorites. Workbook Name: {my_workbook.name}, Workbook ID: {my_workbook.id}") server.favorites.delete_favorite_view(user, my_view) - print("View deleted from favorites. View Name: {}, View ID: {}".format(my_view.name, my_view.id)) + print(f"View deleted from favorites. View Name: {my_view.name}, View ID: {my_view.id}") server.favorites.delete_favorite_datasource(user, my_datasource) print( diff --git a/samples/explore_site.py b/samples/explore_site.py index a2274f1a7..eb9eba0de 100644 --- a/samples/explore_site.py +++ b/samples/explore_site.py @@ -49,7 +49,7 @@ def main(): if args.delete: print("You can only delete the site you are currently in") - print("Delete site `{}`?".format(current_site.name)) + print(f"Delete site `{current_site.name}`?") # server.sites.delete(server.site_id) elif args.create: diff --git a/samples/explore_webhooks.py b/samples/explore_webhooks.py index 77802b1db..f25c41849 100644 --- a/samples/explore_webhooks.py +++ b/samples/explore_webhooks.py @@ -52,11 +52,11 @@ def main(): new_webhook.event = "datasource-created" print(new_webhook) new_webhook = server.webhooks.create(new_webhook) - print("Webhook created. ID: {}".format(new_webhook.id)) + print(f"Webhook created. ID: {new_webhook.id}") # Gets all webhook items all_webhooks, pagination_item = server.webhooks.get() - print("\nThere are {} webhooks on site: ".format(pagination_item.total_available)) + print(f"\nThere are {pagination_item.total_available} webhooks on site: ") print([webhook.name for webhook in all_webhooks]) if all_webhooks: diff --git a/samples/explore_workbook.py b/samples/explore_workbook.py index 57f88aa07..f51639ab3 100644 --- a/samples/explore_workbook.py +++ b/samples/explore_workbook.py @@ -59,13 +59,13 @@ def main(): if default_project is not None: new_workbook = TSC.WorkbookItem(default_project.id) new_workbook = server.workbooks.publish(new_workbook, args.publish, overwrite_true) - print("Workbook published. ID: {}".format(new_workbook.id)) + print(f"Workbook published. ID: {new_workbook.id}") else: print("Publish failed. Could not find the default project.") # Gets all workbook items all_workbooks, pagination_item = server.workbooks.get() - print("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) + print(f"\nThere are {pagination_item.total_available} workbooks on site: ") print([workbook.name for workbook in all_workbooks]) if all_workbooks: @@ -78,27 +78,22 @@ def main(): # Populate views server.workbooks.populate_views(sample_workbook) - print("\nName of views in {}: ".format(sample_workbook.name)) + print(f"\nName of views in {sample_workbook.name}: ") print([view.name for view in sample_workbook.views]) # Populate connections server.workbooks.populate_connections(sample_workbook) - print("\nConnections for {}: ".format(sample_workbook.name)) - print( - [ - "{0}({1})".format(connection.id, connection.datasource_name) - for connection in sample_workbook.connections - ] - ) + print(f"\nConnections for {sample_workbook.name}: ") + print([f"{connection.id}({connection.datasource_name})" for connection in sample_workbook.connections]) # Update tags and show_tabs flag original_tag_set = set(sample_workbook.tags) sample_workbook.tags.update("a", "b", "c", "d") sample_workbook.show_tabs = True server.workbooks.update(sample_workbook) - print("\nWorkbook's old tag set: {}".format(original_tag_set)) - print("Workbook's new tag set: {}".format(sample_workbook.tags)) - print("Workbook tabbed: {}".format(sample_workbook.show_tabs)) + print(f"\nWorkbook's old tag set: {original_tag_set}") + print(f"Workbook's new tag set: {sample_workbook.tags}") + print(f"Workbook tabbed: {sample_workbook.show_tabs}") # Delete all tags that were added by setting tags to original sample_workbook.tags = original_tag_set @@ -109,8 +104,8 @@ def main(): original_tag_set = set(sample_view.tags) sample_view.tags.add("view_tag") server.views.update(sample_view) - print("\nView's old tag set: {}".format(original_tag_set)) - print("View's new tag set: {}".format(sample_view.tags)) + print(f"\nView's old tag set: {original_tag_set}") + print(f"View's new tag set: {sample_view.tags}") # Delete tag from just one view sample_view.tags = original_tag_set @@ -119,14 +114,14 @@ def main(): if args.download: # Download path = server.workbooks.download(sample_workbook.id, args.download) - print("\nDownloaded workbook to {}".format(path)) + print(f"\nDownloaded workbook to {path}") if args.preview_image: # Populate workbook preview image server.workbooks.populate_preview_image(sample_workbook) with open(args.preview_image, "wb") as f: f.write(sample_workbook.preview_image) - print("\nDownloaded preview image of workbook to {}".format(os.path.abspath(args.preview_image))) + print(f"\nDownloaded preview image of workbook to {os.path.abspath(args.preview_image)}") # get custom views cvs, _ = server.custom_views.get() @@ -153,10 +148,10 @@ def main(): 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))) + print(f"\nDownloaded powerpoint of workbook to {os.path.abspath(args.powerpoint)}") if args.delete: - print("deleting {}".format(c.id)) + print(f"deleting {c.id}") unlucky = TSC.CustomViewItem(c.id) server.custom_views.delete(unlucky.id) diff --git a/samples/export.py b/samples/export.py index f2783fa6e..b2506cf46 100644 --- a/samples/export.py +++ b/samples/export.py @@ -37,8 +37,11 @@ def main(): "--csv", dest="type", action="store_const", const=("populate_csv", "CSVRequestOptions", "csv", "csv") ) # other options shown in explore_workbooks: workbook.download, workbook.preview_image - + parser.add_argument( + "--language", help="Text such as 'Average' will appear in this language. Use values like fr, de, es, en" + ) parser.add_argument("--workbook", action="store_true") + parser.add_argument("--custom_view", action="store_true") parser.add_argument("--file", "-f", help="filename to store the exported data") parser.add_argument("--filter", "-vf", metavar="COLUMN:VALUE", help="View filter to apply to the view") @@ -56,14 +59,16 @@ def main(): print("Connected") if args.workbook: item = server.workbooks.get_by_id(args.resource_id) + elif args.custom_view: + item = server.custom_views.get_by_id(args.resource_id) else: item = server.views.get_by_id(args.resource_id) if not item: - print("No item found for id {}".format(args.resource_id)) + print(f"No item found for id {args.resource_id}") exit(1) - print("Item found: {}".format(item.name)) + print(f"Item found: {item.name}") # We have a number of different types and functions for each different export type. # We encode that information above in the const=(...) parameter to the add_argument function to make # the code automatically adapt for the type of export the user is doing. @@ -72,18 +77,22 @@ def main(): populate = getattr(server.views, populate_func_name) if args.workbook: populate = getattr(server.workbooks, populate_func_name) + elif args.custom_view: + populate = getattr(server.custom_views, populate_func_name) option_factory = getattr(TSC, option_factory_name) + options: TSC.PDFRequestOptions = option_factory() if args.filter: - options = option_factory().vf(*args.filter.split(":")) - else: - options = None + options = options.vf(*args.filter.split(":")) + + if args.language: + options.language = args.language if args.file: filename = args.file else: - filename = "out.{}".format(extension) + filename = f"out-{options.language}.{extension}" populate(item, options) with open(filename, "wb") as f: diff --git a/samples/extracts.py b/samples/extracts.py index 9bd87a473..c0dd885bc 100644 --- a/samples/extracts.py +++ b/samples/extracts.py @@ -1,13 +1,7 @@ #### -# This script demonstrates how to use the Tableau Server Client -# to interact with workbooks. It explores the different -# functions that the Server API supports on workbooks. -# -# With no flags set, this sample will query all workbooks, -# pick one workbook and populate its connections/views, and update -# the workbook. Adding flags will demonstrate the specific feature -# on top of the general operations. -#### +# This script demonstrates how to use the Tableau Server Client to interact with extracts. +# It explores the different functions that the REST API supports on extracts. +##### import argparse import logging @@ -47,7 +41,7 @@ def main(): with server.auth.sign_in(tableau_auth): # Gets all workbook items all_workbooks, pagination_item = server.workbooks.get() - print("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) + print(f"\nThere are {pagination_item.total_available} workbooks on site: ") print([workbook.name for workbook in all_workbooks]) if all_workbooks: diff --git a/samples/filter_sort_groups.py b/samples/filter_sort_groups.py index 042af32e2..1694bf0f5 100644 --- a/samples/filter_sort_groups.py +++ b/samples/filter_sort_groups.py @@ -47,7 +47,7 @@ def main(): 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=True) + server = TSC.Server(args.server, use_server_version=True, http_options={"verify": False}) with server.auth.sign_in(tableau_auth): group_name = "SALES NORTHWEST" # Try to create a group named "SALES NORTHWEST" @@ -57,37 +57,36 @@ def main(): # Try to create a group named "SALES ROMANIA" create_example_group(group_name, server) - # URL Encode the name of the group that we want to filter on - # i.e. turn spaces into plus signs - filter_group_name = urllib.parse.quote_plus(group_name) + # we no longer need to encode the space options = TSC.RequestOptions() - options.filter.add( - TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, filter_group_name) - ) + options.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, group_name)) filtered_groups, _ = server.groups.get(req_options=options) # Result can either be a matching group or an empty list if filtered_groups: - group_name = filtered_groups.pop().name - print(group_name) + group = filtered_groups.pop() + print(group) else: - error = "No project named '{}' found".format(filter_group_name) + error = f"No group named '{group_name}' found" print(error) + print("---") + # Or, try the above with the django style filtering try: - group = server.groups.filter(name=filter_group_name)[0] + group = server.groups.filter(name=group_name)[0] + print(group) except IndexError: - print(f"No project named '{filter_group_name}' found") - else: - print(group.name) + print(f"No group named '{group_name}' found") + + print("====") options = TSC.RequestOptions() options.filter.add( TSC.Filter( TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.In, - ["SALES+NORTHWEST", "SALES+ROMANIA", "this_group"], + ["SALES NORTHWEST", "SALES ROMANIA", "this_group"], ) ) @@ -98,13 +97,20 @@ def main(): for group in matching_groups: print(group.name) + print("----") # or, try the above with the django style filtering. - - groups = ["SALES NORTHWEST", "SALES ROMANIA", "this_group"] - groups = [urllib.parse.quote_plus(group) for group in groups] - for group in server.groups.filter(name__in=groups).sort("-name"): + all_g = server.groups.all() + print(f"Searching locally among {all_g.total_available} groups") + for a in all_g: + print(a) + groups = [urllib.parse.quote_plus(group) for group in ["SALES NORTHWEST", "SALES ROMANIA", "this_group"]] + print(groups) + + for group in server.groups.filter(name__in=groups).order_by("-name"): print(group.name) + print("done") + if __name__ == "__main__": main() diff --git a/samples/filter_sort_projects.py b/samples/filter_sort_projects.py index 7aa62a5c1..6c3a85dcd 100644 --- a/samples/filter_sort_projects.py +++ b/samples/filter_sort_projects.py @@ -68,7 +68,7 @@ def main(): project_name = filtered_projects.pop().name print(project_name) else: - error = "No project named '{}' found".format(filter_project_name) + error = f"No project named '{filter_project_name}' found" print(error) create_example_project(name="Example 1", server=server) diff --git a/samples/getting_started/1_hello_server.py b/samples/getting_started/1_hello_server.py index 454b225de..5f8cfa238 100644 --- a/samples/getting_started/1_hello_server.py +++ b/samples/getting_started/1_hello_server.py @@ -12,8 +12,8 @@ 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(f"Connected to {server.server_info.baseurl}") + print(f"Server information: {server.server_info}") print("Sign up for a test site at https://www.tableau.com/developer") diff --git a/samples/getting_started/2_hello_site.py b/samples/getting_started/2_hello_site.py index d62896059..8635947a8 100644 --- a/samples/getting_started/2_hello_site.py +++ b/samples/getting_started/2_hello_site.py @@ -19,7 +19,7 @@ def main(): 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)) + print(f"Connected to {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 @@ -39,7 +39,7 @@ def main(): with server.auth.sign_in(tableau_auth): projects, pagination = server.projects.get() if projects: - print("{} projects".format(pagination.total_available)) + print(f"{pagination.total_available} projects") project = projects[0] print(project.name) diff --git a/samples/getting_started/3_hello_universe.py b/samples/getting_started/3_hello_universe.py index 21de97831..a2c4301d0 100644 --- a/samples/getting_started/3_hello_universe.py +++ b/samples/getting_started/3_hello_universe.py @@ -17,7 +17,7 @@ def main(): 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)) + print(f"Connected to {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/ @@ -36,55 +36,55 @@ def main(): with server.auth.sign_in(tableau_auth): projects, pagination = server.projects.get() if projects: - print("{} projects".format(pagination.total_available)) + print(f"{pagination.total_available} projects") for project in projects: print(project.name) workbooks, pagination = server.datasources.get() if workbooks: - print("{} workbooks".format(pagination.total_available)) + print(f"{pagination.total_available} workbooks") print(workbooks[0]) views, pagination = server.views.get() if views: - print("{} views".format(pagination.total_available)) + print(f"{pagination.total_available} views") print(views[0]) datasources, pagination = server.datasources.get() if datasources: - print("{} datasources".format(pagination.total_available)) + print(f"{pagination.total_available} datasources") 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(f"{pagination.total_available} jobs") print(jobs[0]) schedules, pagination = server.schedules.get() if schedules: - print("{} schedules".format(pagination.total_available)) + print(f"{pagination.total_available} schedules") print(schedules[0]) tasks, pagination = server.tasks.get() if tasks: - print("{} tasks".format(pagination.total_available)) + print(f"{pagination.total_available} tasks") print(tasks[0]) webhooks, pagination = server.webhooks.get() if webhooks: - print("{} webhooks".format(pagination.total_available)) + print(f"{pagination.total_available} webhooks") print(webhooks[0]) users, pagination = server.users.get() if users: - print("{} users".format(pagination.total_available)) + print(f"{pagination.total_available} users") print(users[0]) groups, pagination = server.groups.get() if groups: - print("{} groups".format(pagination.total_available)) + print(f"{pagination.total_available} groups") print(groups[0]) diff --git a/samples/initialize_server.py b/samples/initialize_server.py index cb3d9e1d0..cdfaf27a8 100644 --- a/samples/initialize_server.py +++ b/samples/initialize_server.py @@ -51,7 +51,7 @@ def main(): # Create the site if it doesn't exist if existing_site is None: - print("Site not found: {0} Creating it...".format(args.site_id)) + print(f"Site not found: {args.site_id} Creating it...") new_site = TSC.SiteItem( name=args.site_id, content_url=args.site_id.replace(" ", ""), @@ -59,7 +59,7 @@ def main(): ) server.sites.create(new_site) else: - print("Site {0} exists. Moving on...".format(args.site_id)) + print(f"Site {args.site_id} exists. Moving on...") ################################################################################ # Step 3: Sign-in to our target site @@ -81,7 +81,7 @@ def main(): # Create our project if it doesn't exist if project is None: - print("Project not found: {0} Creating it...".format(args.project)) + print(f"Project not found: {args.project} Creating it...") new_project = TSC.ProjectItem(name=args.project) project = server_upload.projects.create(new_project) @@ -100,7 +100,7 @@ def publish_datasources_to_site(server_object, project, folder): for fname in glob.glob(path): new_ds = TSC.DatasourceItem(project.id) new_ds = server_object.datasources.publish(new_ds, fname, server_object.PublishMode.Overwrite) - print("Datasource published. ID: {0}".format(new_ds.id)) + print(f"Datasource published. ID: {new_ds.id}") def publish_workbooks_to_site(server_object, project, folder): @@ -110,7 +110,7 @@ def publish_workbooks_to_site(server_object, project, folder): new_workbook = TSC.WorkbookItem(project.id) new_workbook.show_tabs = True new_workbook = server_object.workbooks.publish(new_workbook, fname, server_object.PublishMode.Overwrite) - print("Workbook published. ID: {0}".format(new_workbook.id)) + print(f"Workbook published. ID: {new_workbook.id}") if __name__ == "__main__": diff --git a/samples/list.py b/samples/list.py index 8d72fb620..2675a2954 100644 --- a/samples/list.py +++ b/samples/list.py @@ -48,6 +48,9 @@ def main(): "webhooks": server.webhooks, "workbook": server.workbooks, }.get(args.resource_type) + if endpoint is None: + print("Resource type not found.") + sys.exit(1) options = TSC.RequestOptions() options.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Direction.Desc)) @@ -59,7 +62,7 @@ def main(): print(resource.name[:18], " ") # , resource._connections()) if count > 100: break - print("Total: {}".format(count)) + print(f"Total: {count}") if __name__ == "__main__": diff --git a/samples/login.py b/samples/login.py index 6a3e9e8b3..bc99385b3 100644 --- a/samples/login.py +++ b/samples/login.py @@ -7,9 +7,15 @@ import argparse import getpass import logging +import os import tableauserverclient as TSC -import env + + +def get_env(key): + if key in os.environ: + return os.environ[key] + return None # If a sample has additional arguments, then it should copy this code and insert them after the call to @@ -20,13 +26,13 @@ def set_up_and_log_in(): sample_define_common_options(parser) args = parser.parse_args() if not args.server: - args.server = env.server + args.server = get_env("SERVER") if not args.site: - args.site = env.site + args.site = get_env("SITE") if not args.token_name: - args.token_name = env.token_name + args.token_name = get_env("TOKEN_NAME") if not args.token_value: - args.token_value = env.token_value + args.token_value = get_env("TOKEN_VALUE") args.logging_level = "debug" server = sample_connect_to_server(args) @@ -59,7 +65,7 @@ def sample_connect_to_server(args): password = args.password or getpass.getpass("Password: ") tableau_auth = TSC.TableauAuth(args.username, password, site_id=args.site) - print("\nSigning in...\nServer: {}\nSite: {}\nUsername: {}".format(args.server, args.site, args.username)) + print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\nUsername: {args.username}") else: # Trying to authenticate using personal access tokens. @@ -68,7 +74,7 @@ def sample_connect_to_server(args): tableau_auth = TSC.PersonalAccessTokenAuth( token_name=args.token_name, personal_access_token=token, site_id=args.site ) - print("\nSigning in...\nServer: {}\nSite: {}\nToken name: {}".format(args.server, args.site, args.token_name)) + print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\nToken name: {args.token_name}") if not tableau_auth: raise TabError("Did not create authentication object. Check arguments.") @@ -79,10 +85,7 @@ def sample_connect_to_server(args): # Make sure we use an updated version of the rest apis, and pass in our cert handling choice server = TSC.Server(args.server, use_server_version=True, http_options={"verify": check_ssl_certificate}) server.auth.sign_in(tableau_auth) - server.version = "2.6" - new_site: TSC.SiteItem = TSC.SiteItem("cdnear", content_url=env.site) - server.auth.switch_site(new_site) - print("Logged in successfully") + server.version = "3.19" return server diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index 47af1f2f9..e82c75cf9 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -59,7 +59,7 @@ def main(): # Step 3: Download workbook to a temp directory if len(all_workbooks) == 0: - print("No workbook named {} found.".format(args.workbook_name)) + print(f"No workbook named {args.workbook_name} found.") else: tmpdir = tempfile.mkdtemp() try: @@ -68,10 +68,10 @@ def main(): # Step 4: Check if destination site exists, then sign in to the site all_sites, pagination_info = source_server.sites.get() found_destination_site = any( - (True for site in all_sites if args.destination_site.lower() == site.content_url.lower()) + True for site in all_sites if args.destination_site.lower() == site.content_url.lower() ) if not found_destination_site: - error = "No site named {} found.".format(args.destination_site) + error = f"No site named {args.destination_site} found." raise LookupError(error) tableau_auth.site_id = args.destination_site @@ -85,7 +85,7 @@ def main(): new_workbook = dest_server.workbooks.publish( new_workbook, workbook_path, mode=TSC.Server.PublishMode.Overwrite ) - print("Successfully moved {0} ({1})".format(new_workbook.name, new_workbook.id)) + print(f"Successfully moved {new_workbook.name} ({new_workbook.id})") # Step 6: Delete workbook from source site and delete temp directory source_server.workbooks.delete(all_workbooks[0].id) diff --git a/samples/pagination_sample.py b/samples/pagination_sample.py index a7ae6dc89..a68eed4b3 100644 --- a/samples/pagination_sample.py +++ b/samples/pagination_sample.py @@ -57,7 +57,7 @@ def main(): for wb in TSC.Pager(server.workbooks, page_options): print(wb.name) count = count + 1 - print("Total: {}\n".format(count)) + print(f"Total: {count}\n") count = 0 page_options = TSC.RequestOptions(2, 3) @@ -65,7 +65,7 @@ def main(): for wb in TSC.Pager(server.workbooks, page_options): print(wb.name) count = count + 1 - print("Truncated Total: {}\n".format(count)) + print(f"Truncated Total: {count}\n") print("Your id: ", you.name, you.id, "\n") count = 0 @@ -76,7 +76,7 @@ def main(): for wb in TSC.Pager(server.workbooks, filtered_page_options): print(wb.name, " -- ", wb.owner_id) count = count + 1 - print("Filtered Total: {}\n".format(count)) + print(f"Filtered Total: {count}\n") # 2. QuerySet offers a fluent interface on top of the RequestOptions object print("Fetching workbooks again - this time filtered with QuerySet") @@ -90,7 +90,7 @@ def main(): count = count + 1 more = queryset.total_available > count page = page + 1 - print("QuerySet Total: {}".format(count)) + print(f"QuerySet Total: {count}") # 3. QuerySet also allows you to iterate over all objects without explicitly paging. print("Fetching again - this time without manually paging") diff --git a/samples/publish_datasource.py b/samples/publish_datasource.py index 5ac768674..c674e6882 100644 --- a/samples/publish_datasource.py +++ b/samples/publish_datasource.py @@ -21,12 +21,17 @@ import argparse import logging +import os import tableauserverclient as TSC - -import env import tableauserverclient.datetime_helpers +def get_env(key): + if key in os.environ: + return os.environ[key] + return None + + def main(): parser = argparse.ArgumentParser(description="Publish a datasource to server.") # Common options; please keep those in sync across all samples @@ -52,13 +57,13 @@ def main(): args = parser.parse_args() if not args.server: - args.server = env.server + args.server = get_env("SERVER") if not args.site: - args.site = env.site + args.site = get_env("SITE") if not args.token_name: - args.token_name = env.token_name + args.token_name = get_env("TOKEN_NAME") if not args.token_value: - args.token_value = env.token_value + args.token_value = get_env("TOKEN_VALUE") args.logging = "debug" args.file = "C:/dev/tab-samples/5M.tdsx" args.async_ = True @@ -111,15 +116,17 @@ def main(): new_job = server.datasources.publish( new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds, as_job=True ) - print("Datasource published asynchronously. Job ID: {0}".format(new_job.id)) + print(f"Datasource published asynchronously. Job ID: {new_job.id}") else: # Normal publishing, returns a datasource_item new_datasource = server.datasources.publish( new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds ) print( - "{0}Datasource published. Datasource ID: {1}".format( - new_datasource.id, tableauserverclient.datetime_helpers.timestamp() + ( + "{}Datasource published. Datasource ID: {}".format( + new_datasource.id, tableauserverclient.datetime_helpers.timestamp() + ) ) ) print("\t\tClosing connection") diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 8a9f45279..d31978c0f 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -80,7 +80,7 @@ def main(): as_job=args.as_job, skip_connection_check=args.skip_connection_check, ) - print("Workbook published. JOB ID: {0}".format(new_job.id)) + print(f"Workbook published. JOB ID: {new_job.id}") else: new_workbook = server.workbooks.publish( new_workbook, @@ -90,7 +90,7 @@ def main(): as_job=args.as_job, skip_connection_check=args.skip_connection_check, ) - print("Workbook published. ID: {0}".format(new_workbook.id)) + print(f"Workbook published. ID: {new_workbook.id}") else: error = "The default project could not be found." raise LookupError(error) diff --git a/samples/query_permissions.py b/samples/query_permissions.py index 4e509cd97..3309acd90 100644 --- a/samples/query_permissions.py +++ b/samples/query_permissions.py @@ -57,17 +57,15 @@ def main(): permissions = resource.permissions # Print result - print( - "\n{0} permission rule(s) found for {1} {2}.".format(len(permissions), args.resource_type, args.resource_id) - ) + print(f"\n{len(permissions)} permission rule(s) found for {args.resource_type} {args.resource_id}.") for permission in permissions: grantee = permission.grantee capabilities = permission.capabilities - print("\nCapabilities for {0} {1}:".format(grantee.tag_name, grantee.id)) + print(f"\nCapabilities for {grantee.tag_name} {grantee.id}:") for capability in capabilities: - print("\t{0} - {1}".format(capability, capabilities[capability])) + print(f"\t{capability} - {capabilities[capability]}") if __name__ == "__main__": diff --git a/samples/refresh_tasks.py b/samples/refresh_tasks.py index 03daedf16..c95000898 100644 --- a/samples/refresh_tasks.py +++ b/samples/refresh_tasks.py @@ -19,12 +19,12 @@ def handle_run(server, args): def handle_list(server, _): tasks, pagination = server.tasks.get() for task in tasks: - print("{}".format(task)) + print(f"{task}") def handle_info(server, args): task = server.tasks.get_by_id(args.id) - print("{}".format(task)) + print(f"{task}") def main(): diff --git a/samples/set_refresh_schedule.py b/samples/set_refresh_schedule.py index 56fd12e62..153bb0ee5 100644 --- a/samples/set_refresh_schedule.py +++ b/samples/set_refresh_schedule.py @@ -38,7 +38,7 @@ def usage(args): def make_filter(**kwargs): options = TSC.RequestOptions() - for item, value in kwargs.items(): + for item, value in list(kwargs.items()): name = getattr(TSC.RequestOptions.Field, item) options.filter.add(TSC.Filter(name, TSC.RequestOptions.Operator.Equals, value)) return options diff --git a/samples/update_connection.py b/samples/update_connection.py index 4af6592bc..0fe2f342c 100644 --- a/samples/update_connection.py +++ b/samples/update_connection.py @@ -45,7 +45,7 @@ def main(): update_function = endpoint.update_connection resource = endpoint.get_by_id(args.resource_id) endpoint.populate_connections(resource) - connections = list(filter(lambda x: x.id == args.connection_id, resource.connections)) + connections = list([x for x in resource.connections if x.id == args.connection_id]) assert len(connections) == 1 connection = connections[0] connection.username = args.datasource_username diff --git a/samples/update_workbook_data_acceleration.py b/samples/update_workbook_data_acceleration.py deleted file mode 100644 index 75f12262f..000000000 --- a/samples/update_workbook_data_acceleration.py +++ /dev/null @@ -1,109 +0,0 @@ -#### -# 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 index 9e4d63dc1..c23e3717f 100644 --- a/samples/update_workbook_data_freshness_policy.py +++ b/samples/update_workbook_data_freshness_policy.py @@ -45,7 +45,7 @@ def main(): 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(f"\nThere are {pagination_item.total_available} workbooks on site: ") print([workbook.name for workbook in all_workbooks]) if all_workbooks: diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index bab2cf05f..e0a7abb64 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -32,11 +32,13 @@ PermissionsRule, PersonalAccessTokenAuth, ProjectItem, + Resource, RevisionItem, ScheduleItem, SiteItem, ServerInfoItem, SubscriptionItem, + TableauItem, TableItem, TableauAuth, Target, @@ -56,6 +58,7 @@ PDFRequestOptions, RequestOptions, MissingRequiredFieldError, + FailedSignInError, NotSignedInError, ServerResponseError, Filter, @@ -65,65 +68,68 @@ ) __all__ = [ - "get_versions", - "DEFAULT_NAMESPACE", "BackgroundJobItem", "BackgroundJobItem", "ColumnItem", "ConnectionCredentials", "ConnectionItem", + "CSVRequestOptions", "CustomViewItem", - "DQWItem", "DailyInterval", "DataAlertItem", "DatabaseItem", "DataFreshnessPolicyItem", "DatasourceItem", + "DEFAULT_NAMESPACE", + "DQWItem", + "ExcelRequestOptions", + "FailedSignInError", "FavoriteItem", + "FileuploadItem", + "Filter", "FlowItem", "FlowRunItem", - "FileuploadItem", + "get_versions", "GroupItem", "GroupSetItem", "HourlyInterval", + "ImageRequestOptions", "IntervalItem", "JobItem", "JWTAuth", + "LinkedTaskFlowRunItem", + "LinkedTaskItem", + "LinkedTaskStepItem", "MetricItem", + "MissingRequiredFieldError", "MonthlyInterval", + "NotSignedInError", + "Pager", "PaginationItem", + "PDFRequestOptions", "Permission", "PermissionsRule", "PersonalAccessTokenAuth", "ProjectItem", + "RequestOptions", + "Resource", "RevisionItem", "ScheduleItem", - "SiteItem", + "Server", "ServerInfoItem", + "ServerResponseError", + "SiteItem", + "Sort", "SubscriptionItem", - "TableItem", "TableauAuth", + "TableauItem", + "TableItem", "Target", "TaskItem", "UserItem", "ViewItem", + "VirtualConnectionItem", "WebhookItem", "WeeklyInterval", "WorkbookItem", - "CSVRequestOptions", - "ExcelRequestOptions", - "ImageRequestOptions", - "PDFRequestOptions", - "RequestOptions", - "MissingRequiredFieldError", - "NotSignedInError", - "ServerResponseError", - "Filter", - "Pager", - "Server", - "Sort", - "LinkedTaskItem", - "LinkedTaskStepItem", - "LinkedTaskFlowRunItem", - "VirtualConnectionItem", ] diff --git a/tableauserverclient/_version.py b/tableauserverclient/_version.py index d47374097..79dbed1d8 100644 --- a/tableauserverclient/_version.py +++ b/tableauserverclient/_version.py @@ -84,7 +84,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= stderr=(subprocess.PIPE if hide_stderr else None), ) break - except EnvironmentError: + except OSError: e = sys.exc_info()[1] if e.errno == errno.ENOENT: continue @@ -94,7 +94,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= return None, None else: if verbose: - print("unable to find command, tried %s" % (commands,)) + print(f"unable to find command, tried {commands}") return None, None stdout = p.communicate()[0].strip() if sys.version_info[0] >= 3: @@ -131,7 +131,7 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): root = os.path.dirname(root) # up a level if verbose: - print("Tried directories %s but none started with prefix %s" % (str(rootdirs), parentdir_prefix)) + print(f"Tried directories {str(rootdirs)} but none started with prefix {parentdir_prefix}") raise NotThisMethod("rootdir doesn't start with parentdir_prefix") @@ -144,7 +144,7 @@ def git_get_keywords(versionfile_abs): # _version.py. keywords = {} try: - f = open(versionfile_abs, "r") + f = open(versionfile_abs) for line in f.readlines(): if line.strip().startswith("git_refnames ="): mo = re.search(r'=\s*"(.*)"', line) @@ -159,7 +159,7 @@ def git_get_keywords(versionfile_abs): if mo: keywords["date"] = mo.group(1) f.close() - except EnvironmentError: + except OSError: pass return keywords @@ -183,11 +183,11 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): if verbose: print("keywords are unexpanded, not using") raise NotThisMethod("unexpanded keywords, not a git-archive tarball") - refs = set([r.strip() for r in refnames.strip("()").split(",")]) + refs = {r.strip() for r in refnames.strip("()").split(",")} # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. TAG = "tag: " - tags = set([r[len(TAG) :] for r in refs if r.startswith(TAG)]) + tags = {r[len(TAG) :] for r in refs if r.startswith(TAG)} if not tags: # Either we're using git < 1.8.3, or there really are no tags. We use # a heuristic: assume all version tags have a digit. The old git %d @@ -196,7 +196,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # between branches and tags. By ignoring refnames without digits, we # filter out many common branch names like "release" and # "stabilization", as well as "HEAD" and "master". - tags = set([r for r in refs if re.search(r"\d", r)]) + tags = {r for r in refs if re.search(r"\d", r)} if verbose: print("discarding '%s', no digits" % ",".join(refs - tags)) if verbose: @@ -299,7 +299,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if verbose: fmt = "tag '%s' doesn't start with prefix '%s'" print(fmt % (full_tag, tag_prefix)) - pieces["error"] = "tag '%s' doesn't start with prefix '%s'" % ( + pieces["error"] = "tag '{}' doesn't start with prefix '{}'".format( full_tag, tag_prefix, ) diff --git a/tableauserverclient/config.py b/tableauserverclient/config.py index 63872398f..a75112754 100644 --- a/tableauserverclient/config.py +++ b/tableauserverclient/config.py @@ -6,11 +6,13 @@ DELAY_SLEEP_SECONDS = 0.1 -# The maximum size of a file that can be published in a single request is 64MB -FILESIZE_LIMIT_MB = 64 - class Config: + # The maximum size of a file that can be published in a single request is 64MB + @property + def FILESIZE_LIMIT_MB(self): + return min(int(os.getenv("TSC_FILESIZE_LIMIT_MB", 64)), 64) + # For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks @property def CHUNK_SIZE_MB(self): diff --git a/tableauserverclient/models/column_item.py b/tableauserverclient/models/column_item.py index df936e315..3a7416e28 100644 --- a/tableauserverclient/models/column_item.py +++ b/tableauserverclient/models/column_item.py @@ -3,7 +3,7 @@ from .property_decorators import property_not_empty -class ColumnItem(object): +class ColumnItem: def __init__(self, name, description=None): self._id = None self.description = description diff --git a/tableauserverclient/models/connection_credentials.py b/tableauserverclient/models/connection_credentials.py index d61bbb751..bb2cbbba9 100644 --- a/tableauserverclient/models/connection_credentials.py +++ b/tableauserverclient/models/connection_credentials.py @@ -1,7 +1,7 @@ from .property_decorators import property_is_boolean -class ConnectionCredentials(object): +class ConnectionCredentials: """Connection Credentials for Workbooks and Datasources publish request. Consider removing this object and other variables holding secrets diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 62ff530c9..937e43481 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -1,5 +1,5 @@ import logging -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -8,7 +8,7 @@ from tableauserverclient.helpers.logging import logger -class ConnectionItem(object): +class ConnectionItem: def __init__(self): self._datasource_id: Optional[str] = None self._datasource_name: Optional[str] = None @@ -48,7 +48,7 @@ def query_tagging(self, value: Optional[bool]): # if connection type = hyper, Snowflake, or Teradata, we can't change this value: it is always true if self._connection_type in ["hyper", "snowflake", "teradata"]: logger.debug( - "Cannot update value: Query tagging is always enabled for {} connections".format(self._connection_type) + f"Cannot update value: Query tagging is always enabled for {self._connection_type} connections" ) return self._query_tagging = value @@ -59,7 +59,7 @@ def __repr__(self): ) @classmethod - def from_response(cls, resp, ns) -> List["ConnectionItem"]: + def from_response(cls, resp, ns) -> list["ConnectionItem"]: all_connection_items = list() parsed_response = fromstring(resp) all_connection_xml = parsed_response.findall(".//t:connection", namespaces=ns) @@ -82,7 +82,7 @@ def from_response(cls, resp, ns) -> List["ConnectionItem"]: return all_connection_items @classmethod - def from_xml_element(cls, parsed_response, ns) -> List["ConnectionItem"]: + def from_xml_element(cls, parsed_response, ns) -> list["ConnectionItem"]: """ @@ -93,7 +93,7 @@ def from_xml_element(cls, parsed_response, ns) -> List["ConnectionItem"]: """ - all_connection_items: List["ConnectionItem"] = list() + all_connection_items: list["ConnectionItem"] = list() all_connection_xml = parsed_response.findall(".//t:connection", namespaces=ns) for connection_xml in all_connection_xml: diff --git a/tableauserverclient/models/custom_view_item.py b/tableauserverclient/models/custom_view_item.py index 246a19e7f..a0c0a9844 100644 --- a/tableauserverclient/models/custom_view_item.py +++ b/tableauserverclient/models/custom_view_item.py @@ -2,7 +2,8 @@ from defusedxml import ElementTree from defusedxml.ElementTree import fromstring, tostring -from typing import Callable, List, Optional +from typing import Callable, Optional +from collections.abc import Iterator from .exceptions import UnpopulatedPropertyError from .user_item import UserItem @@ -11,12 +12,14 @@ from ..datetime_helpers import parse_datetime -class CustomViewItem(object): +class CustomViewItem: def __init__(self, id: Optional[str] = None, name: Optional[str] = None) -> None: self._content_url: Optional[str] = None # ? self._created_at: Optional["datetime"] = None self._id: Optional[str] = id self._image: Optional[Callable[[], bytes]] = None + self._pdf: Optional[Callable[[], bytes]] = None + self._csv: Optional[Callable[[], Iterator[bytes]]] = None self._name: Optional[str] = name self._shared: Optional[bool] = False self._updated_at: Optional["datetime"] = None @@ -35,11 +38,17 @@ def __repr__(self: "CustomViewItem"): owner_info = "" if self._owner: owner_info = " owner='{}'".format(self._owner.name or self._owner.id or "unknown") - return "".format(self.id, self.name, view_info, wb_info, owner_info) + return f"" def _set_image(self, image): self._image = image + def _set_pdf(self, pdf): + self._pdf = pdf + + def _set_csv(self, csv): + self._csv = csv + @property def content_url(self) -> Optional[str]: return self._content_url @@ -55,10 +64,24 @@ def id(self) -> Optional[str]: @property def image(self) -> bytes: if self._image is None: - error = "View item must be populated with its png image first." + error = "Custom View item must be populated with its png image first." raise UnpopulatedPropertyError(error) return self._image() + @property + def pdf(self) -> bytes: + if self._pdf is None: + error = "Custom View item must be populated with its pdf first." + raise UnpopulatedPropertyError(error) + return self._pdf() + + @property + def csv(self) -> Iterator[bytes]: + if self._csv is None: + error = "Custom View item must be populated with its csv first." + raise UnpopulatedPropertyError(error) + return self._csv() + @property def name(self) -> Optional[str]: return self._name @@ -104,7 +127,7 @@ def from_response(cls, resp, ns, workbook_id="") -> Optional["CustomViewItem"]: return item[0] @classmethod - def list_from_response(cls, resp, ns, workbook_id="") -> List["CustomViewItem"]: + def list_from_response(cls, resp, ns, workbook_id="") -> list["CustomViewItem"]: return cls.from_xml_element(fromstring(resp), ns, workbook_id) """ @@ -121,7 +144,7 @@ def list_from_response(cls, resp, ns, workbook_id="") -> List["CustomViewItem"]: """ @classmethod - def from_xml_element(cls, parsed_response, ns, workbook_id="") -> List["CustomViewItem"]: + def from_xml_element(cls, parsed_response, ns, workbook_id="") -> list["CustomViewItem"]: all_view_items = list() all_view_xml = parsed_response.findall(".//t:customView", namespaces=ns) for custom_view_xml in all_view_xml: diff --git a/tableauserverclient/models/data_acceleration_report_item.py b/tableauserverclient/models/data_acceleration_report_item.py index 7424e6b95..3a8883bed 100644 --- a/tableauserverclient/models/data_acceleration_report_item.py +++ b/tableauserverclient/models/data_acceleration_report_item.py @@ -1,8 +1,8 @@ from defusedxml.ElementTree import fromstring -class DataAccelerationReportItem(object): - class ComparisonRecord(object): +class DataAccelerationReportItem: + class ComparisonRecord: def __init__( self, site, diff --git a/tableauserverclient/models/data_alert_item.py b/tableauserverclient/models/data_alert_item.py index 65be233e3..7285ee609 100644 --- a/tableauserverclient/models/data_alert_item.py +++ b/tableauserverclient/models/data_alert_item.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -10,7 +10,7 @@ ) -class DataAlertItem(object): +class DataAlertItem: class Frequency: Once = "Once" Frequently = "Frequently" @@ -34,7 +34,7 @@ def __init__(self): self._workbook_name: Optional[str] = None self._project_id: Optional[str] = None self._project_name: Optional[str] = None - self._recipients: Optional[List[str]] = None + self._recipients: Optional[list[str]] = None def __repr__(self) -> str: return " Optional[str]: return self._creatorId @property - def recipients(self) -> List[str]: + def recipients(self) -> list[str]: return self._recipients or list() @property @@ -174,7 +174,7 @@ def _set_values( self._recipients = recipients @classmethod - def from_response(cls, resp, ns) -> List["DataAlertItem"]: + def from_response(cls, resp, ns) -> list["DataAlertItem"]: all_alert_items = list() parsed_response = fromstring(resp) all_alert_xml = parsed_response.findall(".//t:dataAlert", namespaces=ns) diff --git a/tableauserverclient/models/data_freshness_policy_item.py b/tableauserverclient/models/data_freshness_policy_item.py index f567c501c..6e0cb9001 100644 --- a/tableauserverclient/models/data_freshness_policy_item.py +++ b/tableauserverclient/models/data_freshness_policy_item.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as ET -from typing import Optional, Union, List +from typing import Optional from tableauserverclient.models.property_decorators import property_is_enum, property_not_nullable from .interval_item import IntervalItem @@ -50,11 +50,11 @@ class Frequency: Week = "Week" Month = "Month" - def __init__(self, frequency: str, time: str, timezone, interval_item: Optional[List[str]] = None): + 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 + self.interval_item: Optional[list[str]] = interval_item def __repr__(self): return ( @@ -62,11 +62,11 @@ def __repr__(self): ).format(**vars(self)) @property - def interval_item(self) -> Optional[List[str]]: + def interval_item(self) -> Optional[list[str]]: return self._interval_item @interval_item.setter - def interval_item(self, value: List[str]): + def interval_item(self, value: list[str]): self._interval_item = value @property @@ -186,7 +186,7 @@ def parse_week_intervals(interval_values): def parse_month_intervals(interval_values): - error = "Invalid interval value for a monthly frequency: {}.".format(interval_values) + error = f"Invalid interval value for a monthly frequency: {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 diff --git a/tableauserverclient/models/database_item.py b/tableauserverclient/models/database_item.py index dfc58e1bb..4d4604461 100644 --- a/tableauserverclient/models/database_item.py +++ b/tableauserverclient/models/database_item.py @@ -10,7 +10,7 @@ ) -class DatabaseItem(object): +class DatabaseItem: class ContentPermissions: LockedToProject = "LockedToDatabase" ManagedByOwner = "ManagedByOwner" @@ -45,7 +45,7 @@ def __init__(self, name, description=None, content_permissions=None): self._tables = None # Not implemented yet def __str__(self): - return "".format(self._id, self.name) + return f"" def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" @@ -250,7 +250,7 @@ def _set_tables(self, tables): self._tables = tables def _set_default_permissions(self, permissions, content_type): - attr = "_default_{content}_permissions".format(content=content_type) + attr = f"_default_{content_type}_permissions" setattr( self, attr, diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index e4e71c4a2..1b082c157 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -1,7 +1,7 @@ import copy import datetime import xml.etree.ElementTree as ET -from typing import Dict, List, Optional, Set, Tuple +from typing import Optional from defusedxml.ElementTree import fromstring @@ -18,14 +18,14 @@ from tableauserverclient.models.tag_item import TagItem -class DatasourceItem(object): +class DatasourceItem: class AskDataEnablement: Enabled = "Enabled" Disabled = "Disabled" SiteDefault = "SiteDefault" def __repr__(self): - return "".format( + return "".format( self._id, self.name, self.description or "No Description", @@ -44,7 +44,7 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) self._encrypt_extracts = None self._has_extracts = None self._id: Optional[str] = None - self._initial_tags: Set = set() + self._initial_tags: set = set() self._project_name: Optional[str] = None self._revisions = None self._size: Optional[int] = None @@ -55,7 +55,7 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) self.name = name self.owner_id: Optional[str] = None self.project_id = project_id - self.tags: Set[str] = set() + self.tags: set[str] = set() self._permissions = None self._data_quality_warnings = None @@ -72,14 +72,14 @@ def ask_data_enablement(self, value: Optional[AskDataEnablement]): self._ask_data_enablement = value @property - def connections(self) -> Optional[List[ConnectionItem]]: + def connections(self) -> Optional[list[ConnectionItem]]: if self._connections is None: error = "Datasource item must be populated with connections first." raise UnpopulatedPropertyError(error) return self._connections() @property - def permissions(self) -> Optional[List[PermissionsRule]]: + def permissions(self) -> Optional[list[PermissionsRule]]: if self._permissions is None: error = "Project item must be populated with permissions first." raise UnpopulatedPropertyError(error) @@ -177,7 +177,7 @@ def webpage_url(self) -> Optional[str]: return self._webpage_url @property - def revisions(self) -> List[RevisionItem]: + def revisions(self) -> list[RevisionItem]: if self._revisions is None: error = "Datasource item must be populated with revisions first." raise UnpopulatedPropertyError(error) @@ -309,7 +309,7 @@ def _set_values( self._size = int(size) @classmethod - def from_response(cls, resp: str, ns: Dict) -> List["DatasourceItem"]: + def from_response(cls, resp: str, ns: dict) -> list["DatasourceItem"]: all_datasource_items = list() parsed_response = fromstring(resp) all_datasource_xml = parsed_response.findall(".//t:datasource", namespaces=ns) @@ -326,7 +326,7 @@ def from_xml(cls, datasource_xml, ns): return datasource_item @staticmethod - def _parse_element(datasource_xml: ET.Element, ns: Dict) -> Tuple: + def _parse_element(datasource_xml: ET.Element, ns: dict) -> tuple: id_ = datasource_xml.get("id", None) name = datasource_xml.get("name", None) datasource_type = datasource_xml.get("type", None) diff --git a/tableauserverclient/models/dqw_item.py b/tableauserverclient/models/dqw_item.py index ada041481..fbda9d9f2 100644 --- a/tableauserverclient/models/dqw_item.py +++ b/tableauserverclient/models/dqw_item.py @@ -3,7 +3,7 @@ from tableauserverclient.datetime_helpers import parse_datetime -class DQWItem(object): +class DQWItem: class WarningType: WARNING = "WARNING" DEPRECATED = "DEPRECATED" diff --git a/tableauserverclient/models/favorites_item.py b/tableauserverclient/models/favorites_item.py index caff755e3..4fea280f7 100644 --- a/tableauserverclient/models/favorites_item.py +++ b/tableauserverclient/models/favorites_item.py @@ -1,28 +1,27 @@ import logging +from typing import Union from defusedxml.ElementTree import fromstring -from tableauserverclient.models.tableau_types import TableauItem +from tableauserverclient.models.tableau_types import TableauItem 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 -from typing import Dict, List, Union -FavoriteType = Dict[ +FavoriteType = dict[ str, - List[TableauItem], + list[TableauItem], ] class FavoriteItem: @classmethod - def from_response(cls, xml: str, namespace: Dict) -> FavoriteType: + def from_response(cls, xml: Union[str, bytes], namespace: dict) -> FavoriteType: favorites: FavoriteType = { "datasources": [], "flows": [], diff --git a/tableauserverclient/models/fileupload_item.py b/tableauserverclient/models/fileupload_item.py index e9bdd25b2..aea4dfe1f 100644 --- a/tableauserverclient/models/fileupload_item.py +++ b/tableauserverclient/models/fileupload_item.py @@ -1,7 +1,7 @@ from defusedxml.ElementTree import fromstring -class FileuploadItem(object): +class FileuploadItem: def __init__(self): self._file_size = None self._upload_session_id = None diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index edce2ec97..9bcad5e89 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -1,7 +1,7 @@ import copy import datetime import xml.etree.ElementTree as ET -from typing import List, Optional, Set +from typing import Optional from defusedxml.ElementTree import fromstring @@ -14,9 +14,9 @@ from tableauserverclient.models.tag_item import TagItem -class FlowItem(object): +class FlowItem: def __repr__(self): - return " None: self._webpage_url: Optional[str] = None self._created_at: Optional[datetime.datetime] = None self._id: Optional[str] = None - self._initial_tags: Set[str] = set() + self._initial_tags: set[str] = set() self._project_name: Optional[str] = None self._updated_at: Optional[datetime.datetime] = None self.name: Optional[str] = name self.owner_id: Optional[str] = None self.project_id: str = project_id - self.tags: Set[str] = set() + self.tags: set[str] = set() self.description: Optional[str] = None self._connections: Optional[ConnectionItem] = None @@ -170,7 +170,7 @@ def _set_values( self.owner_id = owner_id @classmethod - def from_response(cls, resp, ns) -> List["FlowItem"]: + def from_response(cls, resp, ns) -> list["FlowItem"]: all_flow_items = list() parsed_response = fromstring(resp) all_flow_xml = parsed_response.findall(".//t:flow", namespaces=ns) diff --git a/tableauserverclient/models/flow_run_item.py b/tableauserverclient/models/flow_run_item.py index 12281f4f8..f2f1d561f 100644 --- a/tableauserverclient/models/flow_run_item.py +++ b/tableauserverclient/models/flow_run_item.py @@ -1,13 +1,13 @@ import itertools from datetime import datetime -from typing import Dict, List, Optional, Type +from typing import Optional from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime -class FlowRunItem(object): +class FlowRunItem: def __init__(self) -> None: self._id: str = "" self._flow_id: Optional[str] = None @@ -71,7 +71,7 @@ def _set_values( self._background_job_id = background_job_id @classmethod - def from_response(cls: Type["FlowRunItem"], resp: bytes, ns: Optional[Dict]) -> List["FlowRunItem"]: + def from_response(cls: type["FlowRunItem"], resp: bytes, ns: Optional[dict]) -> list["FlowRunItem"]: all_flowrun_items = list() parsed_response = fromstring(resp) all_flowrun_xml = itertools.chain( diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index 6c8f7eb01..6871f8b16 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -1,4 +1,4 @@ -from typing import Callable, List, Optional, TYPE_CHECKING +from typing import Callable, Optional, TYPE_CHECKING from defusedxml.ElementTree import fromstring @@ -11,7 +11,7 @@ from tableauserverclient.server import Pager -class GroupItem(object): +class GroupItem: tag_name: str = "group" class LicenseMode: @@ -27,7 +27,7 @@ def __init__(self, name=None, domain_name=None) -> None: self.domain_name: Optional[str] = domain_name def __repr__(self): - return "{}({!r})".format(self.__class__.__name__, self.__dict__) + return f"{self.__class__.__name__}({self.__dict__!r})" @property def domain_name(self) -> Optional[str]: @@ -79,7 +79,7 @@ def _set_users(self, users: Callable[..., "Pager"]) -> None: self._users = users @classmethod - def from_response(cls, resp, ns) -> List["GroupItem"]: + def from_response(cls, resp, ns) -> list["GroupItem"]: all_group_items = list() parsed_response = fromstring(resp) all_group_xml = parsed_response.findall(".//t:group", namespaces=ns) diff --git a/tableauserverclient/models/groupset_item.py b/tableauserverclient/models/groupset_item.py index ffb57adf5..aa653a79e 100644 --- a/tableauserverclient/models/groupset_item.py +++ b/tableauserverclient/models/groupset_item.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Optional +from typing import Optional import xml.etree.ElementTree as ET from defusedxml.ElementTree import fromstring @@ -13,7 +13,7 @@ class GroupSetItem: def __init__(self, name: Optional[str] = None) -> None: self.name = name self.id: Optional[str] = None - self.groups: List["GroupItem"] = [] + self.groups: list["GroupItem"] = [] self.group_count: int = 0 def __str__(self) -> str: @@ -25,13 +25,13 @@ def __repr__(self) -> str: return self.__str__() @classmethod - def from_response(cls, response: bytes, ns: Dict[str, str]) -> List["GroupSetItem"]: + def from_response(cls, response: bytes, ns: dict[str, str]) -> list["GroupSetItem"]: parsed_response = fromstring(response) all_groupset_xml = parsed_response.findall(".//t:groupSet", namespaces=ns) return [cls.from_xml(xml, ns) for xml in all_groupset_xml] @classmethod - def from_xml(cls, groupset_xml: ET.Element, ns: Dict[str, str]) -> "GroupSetItem": + def from_xml(cls, groupset_xml: ET.Element, ns: dict[str, str]) -> "GroupSetItem": def get_group(group_xml: ET.Element) -> GroupItem: group_item = GroupItem() group_item._id = group_xml.get("id") diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index 444674e19..d7cf891cc 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -1,7 +1,7 @@ from .property_decorators import property_is_valid_time, property_not_nullable -class IntervalItem(object): +class IntervalItem: class Frequency: Hourly = "Hourly" Daily = "Daily" @@ -25,7 +25,7 @@ class Day: LastDay = "LastDay" -class HourlyInterval(object): +class HourlyInterval: def __init__(self, start_time, end_time, interval_value): self.start_time = start_time self.end_time = end_time @@ -73,12 +73,12 @@ def interval(self, intervals): for interval in intervals: # if an hourly interval is a string, then it is a weekDay interval if isinstance(interval, str) and not interval.isnumeric() and not hasattr(IntervalItem.Day, interval): - error = "Invalid weekDay interval {}".format(interval) + error = f"Invalid weekDay interval {interval}" raise ValueError(error) # if an hourly interval is a number, it is an hours or minutes interval if isinstance(interval, (int, float)) and float(interval) not in VALID_INTERVALS: - error = "Invalid interval {} not in {}".format(interval, str(VALID_INTERVALS)) + error = f"Invalid interval {interval} not in {str(VALID_INTERVALS)}" raise ValueError(error) self._interval = intervals @@ -108,7 +108,7 @@ def _interval_type_pairs(self): return interval_type_pairs -class DailyInterval(object): +class DailyInterval: def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values @@ -141,12 +141,12 @@ def interval(self, intervals): for interval in intervals: # if an hourly interval is a string, then it is a weekDay interval if isinstance(interval, str) and not interval.isnumeric() and not hasattr(IntervalItem.Day, interval): - error = "Invalid weekDay interval {}".format(interval) + error = f"Invalid weekDay interval {interval}" raise ValueError(error) # if an hourly interval is a number, it is an hours or minutes interval if isinstance(interval, (int, float)) and float(interval) not in VALID_INTERVALS: - error = "Invalid interval {} not in {}".format(interval, str(VALID_INTERVALS)) + error = f"Invalid interval {interval} not in {str(VALID_INTERVALS)}" raise ValueError(error) self._interval = intervals @@ -176,7 +176,7 @@ def _interval_type_pairs(self): return interval_type_pairs -class WeeklyInterval(object): +class WeeklyInterval: def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values @@ -213,7 +213,7 @@ def _interval_type_pairs(self): return [(IntervalItem.Occurrence.WeekDay, day) for day in self.interval] -class MonthlyInterval(object): +class MonthlyInterval: def __init__(self, start_time, interval_value): self.start_time = start_time diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 155ce668b..cc7cd5811 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -1,5 +1,5 @@ import datetime -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -7,7 +7,7 @@ from tableauserverclient.models.flow_run_item import FlowRunItem -class JobItem(object): +class JobItem: class FinishCode: """ Status codes as documented on @@ -27,7 +27,7 @@ def __init__( started_at: Optional[datetime.datetime] = None, completed_at: Optional[datetime.datetime] = None, finish_code: int = 0, - notes: Optional[List[str]] = None, + notes: Optional[list[str]] = None, mode: Optional[str] = None, workbook_id: Optional[str] = None, datasource_id: Optional[str] = None, @@ -43,7 +43,7 @@ def __init__( self._started_at = started_at self._completed_at = completed_at self._finish_code = finish_code - self._notes: List[str] = notes or [] + self._notes: list[str] = notes or [] self._mode = mode self._workbook_id = workbook_id self._datasource_id = datasource_id @@ -81,7 +81,7 @@ def finish_code(self) -> int: return self._finish_code @property - def notes(self) -> List[str]: + def notes(self) -> list[str]: return self._notes @property @@ -139,7 +139,7 @@ def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" @classmethod - def from_response(cls, xml, ns) -> List["JobItem"]: + def from_response(cls, xml, ns) -> list["JobItem"]: parsed_response = fromstring(xml) all_tasks_xml = parsed_response.findall(".//t:job", namespaces=ns) @@ -191,7 +191,7 @@ def _parse_element(cls, element, ns): ) -class BackgroundJobItem(object): +class BackgroundJobItem: class Status: Pending: str = "Pending" InProgress: str = "InProgress" @@ -270,7 +270,7 @@ def priority(self) -> int: return self._priority @classmethod - def from_response(cls, xml, ns) -> List["BackgroundJobItem"]: + def from_response(cls, xml, ns) -> list["BackgroundJobItem"]: parsed_response = fromstring(xml) all_tasks_xml = parsed_response.findall(".//t:backgroundJob", namespaces=ns) return [cls._parse_element(x, ns) for x in all_tasks_xml] diff --git a/tableauserverclient/models/linked_tasks_item.py b/tableauserverclient/models/linked_tasks_item.py index ae9b60425..14a0e4978 100644 --- a/tableauserverclient/models/linked_tasks_item.py +++ b/tableauserverclient/models/linked_tasks_item.py @@ -1,5 +1,5 @@ import datetime as dt -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -14,7 +14,7 @@ def __init__(self) -> None: self.schedule: Optional[ScheduleItem] = None @classmethod - def from_response(cls, resp: bytes, namespace) -> List["LinkedTaskItem"]: + def from_response(cls, resp: bytes, namespace) -> list["LinkedTaskItem"]: parsed_response = fromstring(resp) return [ cls._parse_element(x, namespace) @@ -35,10 +35,10 @@ def __init__(self) -> None: self.id: Optional[str] = None self.step_number: Optional[int] = None self.stop_downstream_on_failure: Optional[bool] = None - self.task_details: List[LinkedTaskFlowRunItem] = [] + self.task_details: list[LinkedTaskFlowRunItem] = [] @classmethod - def from_task_xml(cls, xml, namespace) -> List["LinkedTaskStepItem"]: + def from_task_xml(cls, xml, namespace) -> list["LinkedTaskStepItem"]: return [cls._parse_element(x, namespace) for x in xml.findall(".//t:linkedTaskSteps[@id]", namespace)] @classmethod @@ -61,7 +61,7 @@ def __init__(self) -> None: self.flow_name: Optional[str] = None @classmethod - def _parse_element(cls, xml, namespace) -> List["LinkedTaskFlowRunItem"]: + def _parse_element(cls, xml, namespace) -> list["LinkedTaskFlowRunItem"]: all_tasks = [] for flow_run in xml.findall(".//t:flowRun[@id]", namespace): task = cls() diff --git a/tableauserverclient/models/metric_item.py b/tableauserverclient/models/metric_item.py index d8ba8e825..432fd861a 100644 --- a/tableauserverclient/models/metric_item.py +++ b/tableauserverclient/models/metric_item.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as ET from datetime import datetime -from typing import List, Optional, Set +from typing import Optional from tableauserverclient.datetime_helpers import parse_datetime from .property_decorators import property_is_boolean, property_is_datetime @@ -8,7 +8,7 @@ from .permissions_item import Permission -class MetricItem(object): +class MetricItem: def __init__(self, name: Optional[str] = None): self._id: Optional[str] = None self._name: Optional[str] = name @@ -21,8 +21,8 @@ def __init__(self, name: Optional[str] = None): self._project_name: Optional[str] = None self._owner_id: Optional[str] = None self._view_id: Optional[str] = None - self._initial_tags: Set[str] = set() - self.tags: Set[str] = set() + self._initial_tags: set[str] = set() + self.tags: set[str] = set() self._permissions: Optional[Permission] = None @property @@ -126,7 +126,7 @@ def from_response( cls, resp: bytes, ns, - ) -> List["MetricItem"]: + ) -> list["MetricItem"]: all_metric_items = list() parsed_response = ET.fromstring(resp) all_metric_xml = parsed_response.findall(".//t:metric", namespaces=ns) diff --git a/tableauserverclient/models/pagination_item.py b/tableauserverclient/models/pagination_item.py index 8cebd1c86..f30519be5 100644 --- a/tableauserverclient/models/pagination_item.py +++ b/tableauserverclient/models/pagination_item.py @@ -1,7 +1,7 @@ from defusedxml.ElementTree import fromstring -class PaginationItem(object): +class PaginationItem: def __init__(self): self._page_number = None self._page_size = None diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 26f4ee7e8..bb3487279 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -1,5 +1,5 @@ import xml.etree.ElementTree as ET -from typing import Dict, List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -36,23 +36,25 @@ class Capability: ShareView = "ShareView" ViewComments = "ViewComments" ViewUnderlyingData = "ViewUnderlyingData" + VizqlDataApiAccess = "VizqlDataApiAccess" WebAuthoring = "WebAuthoring" Write = "Write" RunExplainData = "RunExplainData" CreateRefreshMetrics = "CreateRefreshMetrics" SaveAs = "SaveAs" + PulseMetricDefine = "PulseMetricDefine" def __repr__(self): return "" class PermissionsRule: - def __init__(self, grantee: ResourceReference, capabilities: Dict[str, str]) -> None: + def __init__(self, grantee: ResourceReference, capabilities: dict[str, str]) -> None: self.grantee = grantee self.capabilities = capabilities def __repr__(self): - return "".format(self.grantee, self.capabilities) + return f"" def __eq__(self, other: object) -> bool: if not hasattr(other, "grantee") or not hasattr(other, "capabilities"): @@ -66,7 +68,7 @@ def __and__(self, other: "PermissionsRule") -> "PermissionsRule": if self.capabilities == other.capabilities: return self - capabilities = set((*self.capabilities.keys(), *other.capabilities.keys())) + capabilities = {*self.capabilities.keys(), *other.capabilities.keys()} new_capabilities = {} for capability in capabilities: if (self.capabilities.get(capability), other.capabilities.get(capability)) == ( @@ -86,7 +88,7 @@ def __or__(self, other: "PermissionsRule") -> "PermissionsRule": if self.capabilities == other.capabilities: return self - capabilities = set((*self.capabilities.keys(), *other.capabilities.keys())) + capabilities = {*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)): @@ -100,14 +102,14 @@ def __or__(self, other: "PermissionsRule") -> "PermissionsRule": return PermissionsRule(self.grantee, new_capabilities) @classmethod - def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: + def from_response(cls, resp, ns=None) -> list["PermissionsRule"]: parsed_response = fromstring(resp) rules = [] permissions_rules_list_xml = parsed_response.findall(".//t:granteeCapabilities", namespaces=ns) for grantee_capability_xml in permissions_rules_list_xml: - capability_dict: Dict[str, str] = {} + capability_dict: dict[str, str] = {} grantee = PermissionsRule._parse_grantee_element(grantee_capability_xml, ns) @@ -116,7 +118,7 @@ def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: mode = capability_xml.get("mode") if name is None or mode is None: - logger.error("Capability was not valid: {}".format(capability_xml)) + logger.error(f"Capability was not valid: {capability_xml}") raise UnpopulatedPropertyError() else: capability_dict[name] = mode @@ -127,7 +129,7 @@ def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: return rules @staticmethod - def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[Dict[str, str]]) -> ResourceReference: + def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[dict[str, str]]) -> ResourceReference: """Use Xpath magic and some string splitting to get the right object type from the xml""" # Get the first element in the tree with an 'id' attribute @@ -146,6 +148,6 @@ def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[Dict elif grantee_type == "groupSet": grantee = GroupSetItem.as_reference(grantee_id) else: - raise UnknownGranteeTypeError("No support for grantee type of {}".format(grantee_type)) + raise UnknownGranteeTypeError(f"No support for grantee type of {grantee_type}") return grantee diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 9fb382885..48f27c60c 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -1,6 +1,6 @@ import logging import xml.etree.ElementTree as ET -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -8,14 +8,16 @@ from tableauserverclient.models.property_decorators import property_is_enum, property_not_empty -class ProjectItem(object): +class ProjectItem: + ERROR_MSG = "Project item must be populated with permissions first." + class ContentPermissions: LockedToProject: str = "LockedToProject" ManagedByOwner: str = "ManagedByOwner" LockedToProjectWithoutNested: str = "LockedToProjectWithoutNested" def __repr__(self): - return "".format( + return "".format( self._id, self.name, self.parent_id or "None (Top level)", self.content_permissions or "Not Set" ) @@ -43,6 +45,9 @@ def __init__( self._default_lens_permissions = None self._default_datarole_permissions = None self._default_metric_permissions = None + self._default_virtualconnection_permissions = None + self._default_database_permissions = None + self._default_table_permissions = None @property def content_permissions(self): @@ -56,52 +61,63 @@ def content_permissions(self, value: Optional[str]) -> None: @property def permissions(self): if self._permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._permissions() @property def default_datasource_permissions(self): if self._default_datasource_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_datasource_permissions() @property def default_workbook_permissions(self): if self._default_workbook_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_workbook_permissions() @property def default_flow_permissions(self): if self._default_flow_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_flow_permissions() @property def default_lens_permissions(self): if self._default_lens_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_lens_permissions() @property def default_datarole_permissions(self): if self._default_datarole_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_datarole_permissions() @property def default_metric_permissions(self): if self._default_metric_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_metric_permissions() + @property + def default_virtualconnection_permissions(self): + if self._default_virtualconnection_permissions is None: + raise UnpopulatedPropertyError(self.ERROR_MSG) + return self._default_virtualconnection_permissions() + + @property + def default_database_permissions(self): + if self._default_database_permissions is None: + raise UnpopulatedPropertyError(self.ERROR_MSG) + return self._default_database_permissions() + + @property + def default_table_permissions(self): + if self._default_table_permissions is None: + raise UnpopulatedPropertyError(self.ERROR_MSG) + return self._default_table_permissions() + @property def id(self) -> Optional[str]: return self._id @@ -158,7 +174,7 @@ def _set_permissions(self, permissions): self._permissions = permissions def _set_default_permissions(self, permissions, content_type): - attr = "_default_{content}_permissions".format(content=content_type) + attr = f"_default_{content_type}_permissions" setattr( self, attr, @@ -166,7 +182,7 @@ def _set_default_permissions(self, permissions, content_type): ) @classmethod - def from_response(cls, resp, ns) -> List["ProjectItem"]: + def from_response(cls, resp, ns) -> list["ProjectItem"]: all_project_items = list() parsed_response = fromstring(resp) all_project_xml = parsed_response.findall(".//t:project", namespaces=ns) diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index ce31b1428..5048b3498 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -1,7 +1,8 @@ import datetime import re from functools import wraps -from typing import Any, Container, Optional, Tuple +from typing import Any, Optional +from collections.abc import Container from tableauserverclient.datetime_helpers import parse_datetime @@ -11,7 +12,7 @@ def property_type_decorator(func): @wraps(func) def wrapper(self, value): if value is not None and not hasattr(enum_type, value): - error = "Invalid value: {0}. {1} must be of type {2}.".format(value, func.__name__, enum_type.__name__) + error = f"Invalid value: {value}. {func.__name__} must be of type {enum_type.__name__}." raise ValueError(error) return func(self, value) @@ -24,7 +25,7 @@ def property_is_boolean(func): @wraps(func) def wrapper(self, value): if not isinstance(value, bool): - error = "Boolean expected for {0} flag.".format(func.__name__) + error = f"Boolean expected for {func.__name__} flag." raise ValueError(error) return func(self, value) @@ -35,7 +36,7 @@ def property_not_nullable(func): @wraps(func) def wrapper(self, value): if value is None: - error = "{0} must be defined.".format(func.__name__) + error = f"{func.__name__} must be defined." raise ValueError(error) return func(self, value) @@ -46,7 +47,7 @@ def property_not_empty(func): @wraps(func) def wrapper(self, value): if not value: - error = "{0} must not be empty.".format(func.__name__) + error = f"{func.__name__} must not be empty." raise ValueError(error) return func(self, value) @@ -66,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. @@ -81,7 +82,7 @@ def property_is_int(range: Tuple[int, int], allowed: Optional[Container[Any]] = def property_type_decorator(func): @wraps(func) def wrapper(self, value): - error = "Invalid property defined: '{}'. Integer value expected.".format(value) + error = f"Invalid property defined: '{value}'. Integer value expected." if range is None: if isinstance(value, int): @@ -133,7 +134,7 @@ def wrapper(self, value): return func(self, value) if not isinstance(value, str): raise ValueError( - "Cannot convert {} into a datetime, cannot update {}".format(value.__class__.__name__, func.__name__) + f"Cannot convert {value.__class__.__name__} into a datetime, cannot update {func.__name__}" ) dt = parse_datetime(value) @@ -146,11 +147,11 @@ def property_is_data_acceleration_config(func): @wraps(func) def wrapper(self, value): if not isinstance(value, dict): - raise ValueError("{} is not type 'dict', cannot update {})".format(value.__class__.__name__, func.__name__)) + raise ValueError(f"{value.__class__.__name__} is not type 'dict', cannot update {func.__name__})") 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 = f"{func.__name__} should have 2 keys " error += "'acceleration_enabled' and 'accelerate_now'" - error += "instead you have {}".format(value.keys()) + error += f"instead you have {value.keys()}" raise ValueError(error) return func(self, value) diff --git a/tableauserverclient/models/reference_item.py b/tableauserverclient/models/reference_item.py index 710548fcc..4c1fff564 100644 --- a/tableauserverclient/models/reference_item.py +++ b/tableauserverclient/models/reference_item.py @@ -1,10 +1,10 @@ -class ResourceReference(object): +class ResourceReference: def __init__(self, id_, tag_name): self.id = id_ self.tag_name = tag_name def __str__(self): - return "".format(self._id, self._tag_name) + return f"" __repr__ = __str__ diff --git a/tableauserverclient/models/revision_item.py b/tableauserverclient/models/revision_item.py index a0e6a1bd5..1b4cc6249 100644 --- a/tableauserverclient/models/revision_item.py +++ b/tableauserverclient/models/revision_item.py @@ -1,12 +1,12 @@ from datetime import datetime -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime -class RevisionItem(object): +class RevisionItem: def __init__(self): self._resource_id: Optional[str] = None self._resource_name: Optional[str] = None @@ -56,7 +56,7 @@ def __repr__(self): ) @classmethod - def from_response(cls, resp: bytes, ns, resource_item) -> List["RevisionItem"]: + def from_response(cls, resp: bytes, ns, resource_item) -> list["RevisionItem"]: all_revision_items = list() parsed_response = fromstring(resp) all_revision_xml = parsed_response.findall(".//t:revision", namespaces=ns) diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index e416643ba..e39042058 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -19,7 +19,7 @@ Interval = Union[HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval] -class ScheduleItem(object): +class ScheduleItem: class Type: Extract = "Extract" Flow = "Flow" @@ -336,7 +336,7 @@ def parse_add_to_schedule_response(response, ns): all_task_xml = parsed_response.findall(".//t:task", namespaces=ns) error = ( - "Status {}: {}".format(response.status_code, response.reason) + f"Status {response.status_code}: {response.reason}" if response.status_code < 200 or response.status_code >= 300 else None ) diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py index 57fc51af9..b13f26740 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -6,7 +6,29 @@ from tableauserverclient.helpers.logging import logger -class ServerInfoItem(object): +class ServerInfoItem: + """ + The ServerInfoItem class contains the build and version information for + Tableau Server. The server information is accessed with the + server_info.get() method, which returns an instance of the ServerInfo class. + + Attributes + ---------- + product_version : str + Shows the version of the Tableau Server or Tableau Cloud + (for example, 10.2.0). + + build_number : str + Shows the specific build number (for example, 10200.17.0329.1446). + + rest_api_version : str + Shows the supported REST API version number. Note that this might be + different from the default value specified for the server, with the + Server.version attribute. To take advantage of new features, you should + query the server and set the Server.version to match the supported REST + API version number. + """ + def __init__(self, product_version, build_number, rest_api_version): self._product_version = product_version self._build_number = build_number @@ -40,13 +62,11 @@ def from_response(cls, resp, ns): try: parsed_response = fromstring(resp) except xml.etree.ElementTree.ParseError as error: - logger.info("Unexpected response for ServerInfo: {}".format(resp)) - logger.info(error) + logger.exception(f"Unexpected response for ServerInfo: {resp}") return cls("Unknown", "Unknown", "Unknown") except Exception as error: - logger.info("Unexpected response for ServerInfo: {}".format(resp)) - logger.info(error) - return cls("Unknown", "Unknown", "Unknown") + logger.exception(f"Unexpected response for ServerInfo: {resp}") + raise error product_version_tag = parsed_response.find(".//t:productVersion", namespaces=ns) rest_api_version_tag = parsed_response.find(".//t:restApiVersion", namespaces=ns) diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index b651e5773..e4e146f9c 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -14,13 +14,79 @@ VALID_CONTENT_URL_RE = r"^[a-zA-Z0-9_\-]*$" -from typing import List, Optional, Union, TYPE_CHECKING +from typing import Optional, Union, TYPE_CHECKING if TYPE_CHECKING: from tableauserverclient.server import Server -class SiteItem(object): +class SiteItem: + """ + The SiteItem class contains the members or attributes for the site resources + on Tableau Server or Tableau Cloud. The SiteItem class defines the + information you can request or query from Tableau Server or Tableau Cloud. + The class members correspond to the attributes of a server request or + response payload. + + Attributes + ---------- + name: str + The name of the site. The name of the default site is "". + + content_url: str + The path to the site. + + admin_mode: str + (Optional) For Tableau Server only. Specify ContentAndUsers to allow + site administrators to use the server interface and tabcmd commands to + add and remove users. (Specifying this option does not give site + administrators permissions to manage users using the REST API.) Specify + ContentOnly to prevent site administrators from adding or removing + users. (Server administrators can always add or remove users.) + + user_quota: int + (Optional) Specifies the total number of users for the site. The number + can't exceed the number of licenses activated for the site; and if + tiered capacity attributes are set, then user_quota will equal the sum + of the tiered capacity values, and attempting to set user_quota will + cause an error. + + tier_explorer_capacity: int + tier_creator_capacity: int + tier_viewer_capacity: int + (Optional) The maximum number of licenses for users with the Creator, + Explorer, or Viewer role, respectively, allowed on a site. + + storage_quota: int + (Optional) Specifies the maximum amount of space for the new site, in + megabytes. If you set a quota and the site exceeds it, publishers will + be prevented from uploading new content until the site is under the + limit again. + + disable_subscriptions: bool + (Optional) Specify true to prevent users from being able to subscribe + to workbooks on the specified site. The default is False. + + subscribe_others_enabled: bool + (Optional) Specify false to prevent server administrators, site + administrators, and project or content owners from being able to + subscribe other users to workbooks on the specified site. The default + is True. + + revision_history_enabled: bool + (Optional) Specify true to enable revision history for content resources + (workbooks and datasources). The default is False. + + revision_limit: int + (Optional) Specifies the number of revisions of a content source + (workbook or data source) to allow. On Tableau Server, the default is + 25. + + state: str + Shows the current state of the site (Active or Suspended). + + """ + _user_quota: Optional[int] = None _tier_creator_capacity: Optional[int] = None _tier_explorer_capacity: Optional[int] = None @@ -873,7 +939,7 @@ def _set_values( self.auto_suspend_refresh_inactivity_window = auto_suspend_refresh_inactivity_window @classmethod - def from_response(cls, resp, ns) -> List["SiteItem"]: + def from_response(cls, resp, ns) -> list["SiteItem"]: all_site_items = list() parsed_response = fromstring(resp) all_site_xml = parsed_response.findall(".//t:site", namespaces=ns) diff --git a/tableauserverclient/models/subscription_item.py b/tableauserverclient/models/subscription_item.py index e96fcc448..61c75e2d6 100644 --- a/tableauserverclient/models/subscription_item.py +++ b/tableauserverclient/models/subscription_item.py @@ -1,4 +1,4 @@ -from typing import List, Type, TYPE_CHECKING +from typing import TYPE_CHECKING from defusedxml.ElementTree import fromstring @@ -10,7 +10,7 @@ from .target import Target -class SubscriptionItem(object): +class SubscriptionItem: def __init__(self, subject: str, schedule_id: str, user_id: str, target: "Target") -> None: self._id = None self.attach_image = True @@ -79,7 +79,7 @@ def suspended(self, value: bool) -> None: self._suspended = value @classmethod - def from_response(cls: Type, xml: bytes, ns) -> List["SubscriptionItem"]: + def from_response(cls: type, xml: bytes, ns) -> list["SubscriptionItem"]: parsed_response = fromstring(xml) all_subscriptions_xml = parsed_response.findall(".//t:subscription", namespaces=ns) diff --git a/tableauserverclient/models/table_item.py b/tableauserverclient/models/table_item.py index f9df8a8f3..0afdd4df3 100644 --- a/tableauserverclient/models/table_item.py +++ b/tableauserverclient/models/table_item.py @@ -4,7 +4,7 @@ from .property_decorators import property_not_empty, property_is_boolean -class TableItem(object): +class TableItem: def __init__(self, name, description=None): self._id = None self.description = description diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 10cf58723..7d7981433 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -1,5 +1,5 @@ import abc -from typing import Dict, Optional +from typing import 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" @@ -32,6 +32,43 @@ def deprecate_site_attribute(): # The traditional auth type: username/password class TableauAuth(Credentials): + """ + The TableauAuth class defines the information you can set in a sign-in + request. The class members correspond to the attributes of a server request + or response payload. To use this class, create a new instance, supplying + user name, password, and site information if necessary, and pass the + request object to the Auth.sign_in method. + + Parameters + ---------- + username : str + The user name for the sign-in request. + + password : str + The password for the sign-in request. + + site_id : str, optional + This corresponds to the contentUrl attribute in the Tableau REST API. + The site_id is the portion of the URL that follows the /site/ in the + URL. For example, "MarketingTeam" is the site_id in the following URL + MyServer/#/site/MarketingTeam/projects. To specify the default site on + Tableau Server, you can use an empty string '' (single quotes, no + space). For Tableau Cloud, you must provide a value for the site_id. + + user_id_to_impersonate : str, optional + Specifies the id (not the name) of the user to sign in as. This is not + available for Tableau Online. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD', site_id='CONTENTURL') + >>> server = TSC.Server('https://SERVER_URL', use_server_version=True) + >>> server.auth.sign_in(tableau_auth) + + """ + def __init__( self, username: str, password: str, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None ) -> None: @@ -42,7 +79,7 @@ def __init__( 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): @@ -55,6 +92,43 @@ def __repr__(self): # A Tableau-generated Personal Access Token class PersonalAccessTokenAuth(Credentials): + """ + The PersonalAccessTokenAuth class defines the information you can set in a sign-in + request. The class members correspond to the attributes of a server request + or response payload. To use this class, create a new instance, supplying + token name, token secret, and site information if necessary, and pass the + request object to the Auth.sign_in method. + + Parameters + ---------- + token_name : str + The name of the personal access token. + + personal_access_token : str + The personal access token secret for the sign in request. + + site_id : str, optional + This corresponds to the contentUrl attribute in the Tableau REST API. + The site_id is the portion of the URL that follows the /site/ in the + URL. For example, "MarketingTeam" is the site_id in the following URL + MyServer/#/site/MarketingTeam/projects. To specify the default site on + Tableau Server, you can use an empty string '' (single quotes, no + space). For Tableau Cloud, you must provide a value for the site_id. + + user_id_to_impersonate : str, optional + Specifies the id (not the name) of the user to sign in as. This is not + available for Tableau Online. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> tableau_auth = TSC.PersonalAccessTokenAuth("token_name", "token_secret", site_id='CONTENTURL') + >>> server = TSC.Server('https://SERVER_URL', use_server_version=True) + >>> server.auth.sign_in(tableau_auth) + + """ + def __init__( self, token_name: str, @@ -69,7 +143,7 @@ def __init__( 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, @@ -88,6 +162,42 @@ def __repr__(self): # A standard JWT generated specifically for Tableau class JWTAuth(Credentials): + """ + The JWTAuth class defines the information you can set in a sign-in + request. The class members correspond to the attributes of a server request + or response payload. To use this class, create a new instance, supplying + an encoded JSON Web Token, and site information if necessary, and pass the + request object to the Auth.sign_in method. + + Parameters + ---------- + token : str + The encoded JSON Web Token. + + site_id : str, optional + This corresponds to the contentUrl attribute in the Tableau REST API. + The site_id is the portion of the URL that follows the /site/ in the + URL. For example, "MarketingTeam" is the site_id in the following URL + MyServer/#/site/MarketingTeam/projects. To specify the default site on + Tableau Server, you can use an empty string '' (single quotes, no + space). For Tableau Cloud, you must provide a value for the site_id. + + user_id_to_impersonate : str, optional + Specifies the id (not the name) of the user to sign in as. This is not + available for Tableau Online. + + Examples + -------- + >>> import jwt + >>> import tableauserverclient as TSC + + >>> jwt_token = jwt.encode(...) + >>> tableau_auth = TSC.JWTAuth(token, site_id='CONTENTURL') + >>> server = TSC.Server('https://SERVER_URL', use_server_version=True) + >>> server.auth.sign_in(tableau_auth) + + """ + 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") @@ -95,7 +205,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): diff --git a/tableauserverclient/models/tableau_types.py b/tableauserverclient/models/tableau_types.py index bac072076..01ee3d3a9 100644 --- a/tableauserverclient/models/tableau_types.py +++ b/tableauserverclient/models/tableau_types.py @@ -28,8 +28,8 @@ class Resource: TableauItem = Union[DatasourceItem, FlowItem, MetricItem, ProjectItem, ViewItem, WorkbookItem, VirtualConnectionItem] -def plural_type(content_type: Resource) -> str: +def plural_type(content_type: Union[Resource, str]) -> str: if content_type == Resource.Lens: return "lenses" else: - return "{}s".format(content_type) + return f"{content_type}s" diff --git a/tableauserverclient/models/tag_item.py b/tableauserverclient/models/tag_item.py index afa0a0762..cde755f05 100644 --- a/tableauserverclient/models/tag_item.py +++ b/tableauserverclient/models/tag_item.py @@ -1,16 +1,15 @@ import xml.etree.ElementTree as ET -from typing import Set from defusedxml.ElementTree import fromstring -class TagItem(object): +class TagItem: @classmethod - def from_response(cls, resp: bytes, ns) -> Set[str]: + def from_response(cls, resp: bytes, ns) -> set[str]: return cls.from_xml_element(fromstring(resp), ns) @classmethod - def from_xml_element(cls, parsed_response: ET.Element, ns) -> Set[str]: + def from_xml_element(cls, parsed_response: ET.Element, ns) -> set[str]: all_tags = set() tag_elem = parsed_response.findall(".//t:tag", namespaces=ns) for tag_xml in tag_elem: diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index 01cfcfb11..fa6f782ba 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -8,7 +8,7 @@ from tableauserverclient.models.target import Target -class TaskItem(object): +class TaskItem: class Type: ExtractRefresh = "extractRefresh" DataAcceleration = "dataAcceleration" @@ -48,9 +48,9 @@ def __repr__(self) -> str: ) @classmethod - def from_response(cls, xml, ns, task_type=Type.ExtractRefresh) -> List["TaskItem"]: + def from_response(cls, xml, ns, task_type=Type.ExtractRefresh) -> list["TaskItem"]: parsed_response = fromstring(xml) - all_tasks_xml = parsed_response.findall(".//t:task/t:{}".format(task_type), namespaces=ns) + all_tasks_xml = parsed_response.findall(f".//t:task/t:{task_type}", namespaces=ns) all_tasks = (TaskItem._parse_element(x, ns) for x in all_tasks_xml) diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index fe659575a..365e44c1d 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -2,7 +2,7 @@ import xml.etree.ElementTree as ET from datetime import datetime from enum import IntEnum -from typing import Dict, List, Optional, TYPE_CHECKING, Tuple +from typing import Optional, TYPE_CHECKING from defusedxml.ElementTree import fromstring @@ -18,10 +18,35 @@ from tableauserverclient.server import Pager -class UserItem(object): +class UserItem: + """ + The UserItem class contains the members or attributes for the view + resources on Tableau Server. The UserItem class defines the information you + can request or query from Tableau Server. The class attributes correspond + to the attributes of a server request or response payload. + + + Parameters + ---------- + name: str + The name of the user. + + site_role: str + The role of the user on the site. + + auth_setting: str + Required attribute for Tableau Cloud. How the user autenticates to the + server. + """ + tag_name: str = "user" class Roles: + """ + The Roles class contains the possible roles for a user on Tableau + Server. + """ + Interactor = "Interactor" Publisher = "Publisher" ServerAdministrator = "ServerAdministrator" @@ -43,6 +68,11 @@ class Roles: SupportUser = "SupportUser" class Auth: + """ + The Auth class contains the possible authentication settings for a user + on Tableau Cloud. + """ + OpenID = "OpenID" SAML = "SAML" TableauIDWithMFA = "TableauIDWithMFA" @@ -57,7 +87,7 @@ def __init__( self._id: Optional[str] = None self._last_login: Optional[datetime] = None self._workbooks = None - self._favorites: Optional[Dict[str, List]] = None + self._favorites: Optional[dict[str, list]] = None self._groups = None self.email: Optional[str] = None self.fullname: Optional[str] = None @@ -69,7 +99,7 @@ def __init__( def __str__(self) -> str: str_site_role = self.site_role or "None" - return "".format(self.id, self.name, str_site_role) + return f"" def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" @@ -141,7 +171,7 @@ def workbooks(self) -> "Pager": return self._workbooks() @property - def favorites(self) -> Dict[str, List]: + def favorites(self) -> dict[str, list]: if self._favorites is None: error = "User item must be populated with favorites first." raise UnpopulatedPropertyError(error) @@ -210,12 +240,12 @@ def _set_values( self._domain_name = domain_name @classmethod - def from_response(cls, resp, ns) -> List["UserItem"]: + def from_response(cls, resp, ns) -> list["UserItem"]: element_name = ".//t:user" return cls._parse_xml(element_name, resp, ns) @classmethod - def from_response_as_owner(cls, resp, ns) -> List["UserItem"]: + def from_response_as_owner(cls, resp, ns) -> list["UserItem"]: element_name = ".//t:owner" return cls._parse_xml(element_name, resp, ns) @@ -283,7 +313,7 @@ def _parse_element(user_xml, ns): domain_name, ) - class CSVImport(object): + class CSVImport: """ This class includes hardcoded options and logic for the CSV file format defined for user import https://help.tableau.com/current/server/en-us/users_import.htm @@ -308,7 +338,7 @@ def create_user_from_line(line: str): if line is None or line is False or line == "\n" or line == "": return None line = line.strip().lower() - values: List[str] = list(map(str.strip, line.split(","))) + values: list[str] = list(map(str.strip, line.split(","))) user = UserItem(values[UserItem.CSVImport.ColumnType.USERNAME]) if len(values) > 1: if len(values) > UserItem.CSVImport.ColumnType.MAX: @@ -337,7 +367,7 @@ def create_user_from_line(line: str): # Read through an entire CSV file meant for user import # Return the number of valid lines and a list of all the invalid lines @staticmethod - def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> Tuple[int, List[str]]: + def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> tuple[int, list[str]]: num_valid_lines = 0 invalid_lines = [] csv_file.seek(0) # set to start of file in case it has been read earlier @@ -345,11 +375,11 @@ def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> Tuple[int, L while line and line != "": try: # do not print passwords - logger.info("Reading user {}".format(line[:4])) + logger.info(f"Reading user {line[:4]}") UserItem.CSVImport._validate_import_line_or_throw(line, logger) num_valid_lines += 1 except Exception as exc: - logger.info("Error parsing {}: {}".format(line[:4], exc)) + logger.info(f"Error parsing {line[:4]}: {exc}") invalid_lines.append(line) line = csv_file.readline() return num_valid_lines, invalid_lines @@ -358,7 +388,7 @@ def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> Tuple[int, L # Iterate through each field and validate the given value against hardcoded constraints @staticmethod def _validate_import_line_or_throw(incoming, logger) -> None: - _valid_attributes: List[List[str]] = [ + _valid_attributes: list[list[str]] = [ [], [], [], @@ -373,23 +403,23 @@ def _validate_import_line_or_throw(incoming, logger) -> None: if len(line) > UserItem.CSVImport.ColumnType.MAX: raise AttributeError("Too many attributes in line") username = line[UserItem.CSVImport.ColumnType.USERNAME.value] - logger.debug("> details - {}".format(username)) + logger.debug(f"> details - {username}") UserItem.validate_username_or_throw(username) for i in range(1, len(line)): - logger.debug("column {}: {}".format(UserItem.CSVImport.ColumnType(i).name, line[i])) + logger.debug(f"column {UserItem.CSVImport.ColumnType(i).name}: {line[i]}") UserItem.CSVImport._validate_attribute_value( line[i], _valid_attributes[i], UserItem.CSVImport.ColumnType(i) ) # Given a restricted set of possible values, confirm the item is in that set @staticmethod - def _validate_attribute_value(item: str, possible_values: List[str], column_type) -> None: + def _validate_attribute_value(item: str, possible_values: list[str], column_type) -> None: if item is None or item == "": # value can be empty for any column except user, which is checked elsewhere return if item in possible_values or possible_values == []: return - raise AttributeError("Invalid value {} for {}".format(item, column_type)) + raise AttributeError(f"Invalid value {item} for {column_type}") # https://help.tableau.com/current/server/en-us/csvguidelines.htm#settings_and_site_roles # This logic is hardcoded to match the existing rules for import csv files diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index a26e364a3..dc5f37a48 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -1,7 +1,8 @@ import copy from datetime import datetime from requests import Response -from typing import Callable, Iterator, List, Optional, Set +from typing import Callable, Optional +from collections.abc import Iterator from defusedxml.ElementTree import fromstring @@ -11,13 +12,13 @@ from .tag_item import TagItem -class ViewItem(object): +class ViewItem: def __init__(self) -> None: self._content_url: Optional[str] = None self._created_at: Optional[datetime] = None self._id: Optional[str] = None self._image: Optional[Callable[[], bytes]] = None - self._initial_tags: Set[str] = set() + self._initial_tags: set[str] = set() self._name: Optional[str] = None self._owner_id: Optional[str] = None self._preview_image: Optional[Callable[[], bytes]] = None @@ -29,15 +30,15 @@ def __init__(self) -> None: self._sheet_type: Optional[str] = None self._updated_at: Optional[datetime] = None self._workbook_id: Optional[str] = None - self._permissions: Optional[Callable[[], List[PermissionsRule]]] = None - self.tags: Set[str] = set() + 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( + return "".format( self._id, self.name, self.content_url, self.project_id ) @@ -146,21 +147,21 @@ def data_acceleration_config(self, value): self._data_acceleration_config = value @property - def permissions(self) -> List[PermissionsRule]: + def permissions(self) -> list[PermissionsRule]: if self._permissions is None: error = "View item must be populated with permissions first." raise UnpopulatedPropertyError(error) return self._permissions() - def _set_permissions(self, permissions: Callable[[], List[PermissionsRule]]) -> None: + def _set_permissions(self, permissions: Callable[[], list[PermissionsRule]]) -> None: self._permissions = permissions @classmethod - def from_response(cls, resp: "Response", ns, workbook_id="") -> List["ViewItem"]: + def from_response(cls, resp: "Response", ns, workbook_id="") -> list["ViewItem"]: return cls.from_xml_element(fromstring(resp), ns, workbook_id) @classmethod - def from_xml_element(cls, parsed_response, ns, workbook_id="") -> List["ViewItem"]: + def from_xml_element(cls, parsed_response, ns, workbook_id="") -> list["ViewItem"]: all_view_items = list() all_view_xml = parsed_response.findall(".//t:view", namespaces=ns) for view_xml in all_view_xml: diff --git a/tableauserverclient/models/virtual_connection_item.py b/tableauserverclient/models/virtual_connection_item.py index 76a3b5dea..e9e22be1e 100644 --- a/tableauserverclient/models/virtual_connection_item.py +++ b/tableauserverclient/models/virtual_connection_item.py @@ -1,6 +1,7 @@ import datetime as dt import json -from typing import Callable, Dict, Iterable, List, Optional +from typing import Callable, Optional +from collections.abc import Iterable from xml.etree.ElementTree import Element from defusedxml.ElementTree import fromstring @@ -23,7 +24,7 @@ def __init__(self, name: str) -> None: self._connections: Optional[Callable[[], Iterable[ConnectionItem]]] = None self.project_id: Optional[str] = None self.owner_id: Optional[str] = None - self.content: Optional[Dict[str, dict]] = None + self.content: Optional[dict[str, dict]] = None self.certification_note: Optional[str] = None def __str__(self) -> str: @@ -40,7 +41,7 @@ def id(self) -> Optional[str]: return self._id @property - def permissions(self) -> List[PermissionsRule]: + def permissions(self) -> list[PermissionsRule]: if self._permissions is None: error = "Workbook item must be populated with permissions first." raise UnpopulatedPropertyError(error) @@ -53,12 +54,12 @@ def connections(self) -> Iterable[ConnectionItem]: return self._connections() @classmethod - def from_response(cls, response: bytes, ns: Dict[str, str]) -> List["VirtualConnectionItem"]: + def from_response(cls, response: bytes, ns: dict[str, str]) -> list["VirtualConnectionItem"]: parsed_response = fromstring(response) return [cls.from_xml(xml, ns) for xml in parsed_response.findall(".//t:virtualConnection[@name]", ns)] @classmethod - def from_xml(cls, xml: Element, ns: Dict[str, str]) -> "VirtualConnectionItem": + def from_xml(cls, xml: Element, ns: dict[str, str]) -> "VirtualConnectionItem": v_conn = cls(xml.get("name", "")) v_conn._id = xml.get("id", None) v_conn.webpage_url = xml.get("webpageUrl", None) diff --git a/tableauserverclient/models/webhook_item.py b/tableauserverclient/models/webhook_item.py index e4d5e4aa0..98d821fb4 100644 --- a/tableauserverclient/models/webhook_item.py +++ b/tableauserverclient/models/webhook_item.py @@ -1,6 +1,6 @@ import re import xml.etree.ElementTree as ET -from typing import List, Optional, Tuple, Type +from typing import Optional from defusedxml.ElementTree import fromstring @@ -13,7 +13,7 @@ def _parse_event(events): return NAMESPACE_RE.sub("", event.tag) -class WebhookItem(object): +class WebhookItem: def __init__(self): self._id: Optional[str] = None self.name: Optional[str] = None @@ -45,10 +45,10 @@ def event(self) -> Optional[str]: @event.setter def event(self, value: str) -> None: - self._event = "webhook-source-event-{}".format(value) + self._event = f"webhook-source-event-{value}" @classmethod - def from_response(cls: Type["WebhookItem"], resp: bytes, ns) -> List["WebhookItem"]: + def from_response(cls: type["WebhookItem"], resp: bytes, ns) -> list["WebhookItem"]: all_webhooks_items = list() parsed_response = fromstring(resp) all_webhooks_xml = parsed_response.findall(".//t:webhook", namespaces=ns) @@ -61,7 +61,7 @@ def from_response(cls: Type["WebhookItem"], resp: bytes, ns) -> List["WebhookIte return all_webhooks_items @staticmethod - def _parse_element(webhook_xml: ET.Element, ns) -> Tuple: + def _parse_element(webhook_xml: ET.Element, ns) -> tuple: id = webhook_xml.get("id", None) name = webhook_xml.get("name", None) @@ -82,4 +82,4 @@ def _parse_element(webhook_xml: ET.Element, ns) -> Tuple: return id, name, url, event, owner_id def __repr__(self) -> str: - return "".format(self.id, self.name, self.url, self.event) + return f"" diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 58fd2a9a9..776d041e3 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -2,7 +2,7 @@ import datetime import uuid import xml.etree.ElementTree as ET -from typing import Callable, Dict, List, Optional, Set +from typing import Callable, Optional from defusedxml.ElementTree import fromstring @@ -20,7 +20,85 @@ from .data_freshness_policy_item import DataFreshnessPolicyItem -class WorkbookItem(object): +class WorkbookItem: + """ + The workbook resources for Tableau are defined in the WorkbookItem class. + The class corresponds to the workbook resources you can access using the + Tableau REST API. Some workbook methods take an instance of the WorkbookItem + class as arguments. The workbook item specifies the project. + + Parameters + ---------- + project_id : Optional[str], optional + The project ID for the workbook, by default None. + + name : Optional[str], optional + The name of the workbook, by default None. + + show_tabs : bool, optional + Determines whether the workbook shows tabs for the view. + + Attributes + ---------- + connections : list[ConnectionItem] + The list of data connections (ConnectionItem) for the data sources used + by the workbook. You must first call the workbooks.populate_connections + method to access this data. See the ConnectionItem class. + + content_url : Optional[str] + The name of the workbook as it appears in the URL. + + created_at : Optional[datetime.datetime] + The date and time the workbook was created. + + description : Optional[str] + User-defined description of the workbook. + + id : Optional[str] + The identifier for the workbook. You need this value to query a specific + workbook or to delete a workbook with the get_by_id and delete methods. + + owner_id : Optional[str] + The identifier for the owner (UserItem) of the workbook. + + preview_image : bytes + The thumbnail image for the view. You must first call the + workbooks.populate_preview_image method to access this data. + + project_name : Optional[str] + The name of the project that contains the workbook. + + size: int + The size of the workbook in megabytes. + + hidden_views: Optional[list[str]] + List of string names of views that need to be hidden when the workbook + is published. + + tags: set[str] + The set of tags associated with the workbook. + + updated_at : Optional[datetime.datetime] + The date and time the workbook was last updated. + + views : list[ViewItem] + The list of views (ViewItem) for the workbook. You must first call the + workbooks.populate_views method to access this data. See the ViewItem + class. + + web_page_url : Optional[str] + The full URL for the workbook. + + Examples + -------- + # creating a new instance of a WorkbookItem + >>> import tableauserverclient as TSC + + >>> # Create new workbook_item with project id '3a8b6148-493c-11e6-a621-6f3499394a39' + + >>> new_workbook = TSC.WorkbookItem('3a8b6148-493c-11e6-a621-6f3499394a39') + """ + def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, show_tabs: bool = False) -> None: self._connections = None self._content_url = None @@ -35,15 +113,15 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, self._revisions = None self._size = None self._updated_at = None - self._views: Optional[Callable[[], List[ViewItem]]] = None + self._views: Optional[Callable[[], list[ViewItem]]] = None self.name = name self._description = None self.owner_id: Optional[str] = None # workaround for Personal Space workbooks without a project self.project_id: Optional[str] = project_id or uuid.uuid4().__str__() self.show_tabs = show_tabs - self.hidden_views: Optional[List[str]] = None - self.tags: Set[str] = set() + self.hidden_views: Optional[list[str]] = None + self.tags: set[str] = set() self.data_acceleration_config = { "acceleration_enabled": None, "accelerate_now": None, @@ -56,7 +134,7 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, return None def __str__(self): - return "".format( + return "".format( self._id, self.name, self.content_url, self.project_id ) @@ -64,14 +142,14 @@ def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" @property - def connections(self) -> List[ConnectionItem]: + def connections(self) -> list[ConnectionItem]: if self._connections is None: error = "Workbook item must be populated with connections first." raise UnpopulatedPropertyError(error) return self._connections() @property - def permissions(self) -> List[PermissionsRule]: + def permissions(self) -> list[PermissionsRule]: if self._permissions is None: error = "Workbook item must be populated with permissions first." raise UnpopulatedPropertyError(error) @@ -152,7 +230,7 @@ def updated_at(self) -> Optional[datetime.datetime]: return self._updated_at @property - def views(self) -> List[ViewItem]: + def views(self) -> list[ViewItem]: # Views can be set in an initial workbook response OR by a call # to Server. Without getting too fancy, I think we can rely on # returning a list from the response, until they call @@ -191,7 +269,7 @@ def data_freshness_policy(self, value): self._data_freshness_policy = value @property - def revisions(self) -> List[RevisionItem]: + def revisions(self) -> list[RevisionItem]: if self._revisions is None: error = "Workbook item must be populated with revisions first." raise UnpopulatedPropertyError(error) @@ -203,7 +281,7 @@ def _set_connections(self, connections): def _set_permissions(self, permissions): self._permissions = permissions - def _set_views(self, views: Callable[[], List[ViewItem]]) -> None: + def _set_views(self, views: Callable[[], list[ViewItem]]) -> None: self._views = views def _set_pdf(self, pdf: Callable[[], bytes]) -> None: @@ -316,7 +394,7 @@ def _set_values( self.data_freshness_policy = data_freshness_policy @classmethod - def from_response(cls, resp: str, ns: Dict[str, str]) -> List["WorkbookItem"]: + def from_response(cls, resp: str, ns: dict[str, str]) -> list["WorkbookItem"]: all_workbook_items = list() parsed_response = fromstring(resp) all_workbook_xml = parsed_response.findall(".//t:workbook", namespaces=ns) diff --git a/tableauserverclient/namespace.py b/tableauserverclient/namespace.py index d225ecff6..54ac46d8d 100644 --- a/tableauserverclient/namespace.py +++ b/tableauserverclient/namespace.py @@ -11,7 +11,7 @@ class UnknownNamespaceError(Exception): pass -class Namespace(object): +class Namespace: def __init__(self): self._namespace = {"t": NEW_NAMESPACE} self._detected = False diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index f5cd1d236..87cc9460b 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -11,7 +11,7 @@ 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 tableauserverclient.server.endpoint.exceptions import FailedSignInError, NotSignedInError from tableauserverclient.server.endpoint import ( Auth, @@ -57,6 +57,7 @@ "Sort", "Server", "Pager", + "FailedSignInError", "NotSignedInError", "Auth", "CustomViews", diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 468d469a7..4211bb7ea 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -16,7 +16,7 @@ class Auth(Endpoint): - class contextmgr(object): + class contextmgr: def __init__(self, callback): self._callback = callback @@ -28,7 +28,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): @property def baseurl(self) -> str: - return "{0}/auth".format(self.parent_srv.baseurl) + return f"{self.parent_srv.baseurl}/auth" @api(version="2.0") def sign_in(self, auth_req: "Credentials") -> contextmgr: @@ -41,8 +41,32 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr: optionally a user_id to impersonate. Creates a context manager that will sign out of the server upon exit. + + Parameters + ---------- + auth_req : Credentials + The credentials object to use for signing in. Can be a TableauAuth, + PersonalAccessTokenAuth, or JWTAuth object. + + Returns + ------- + contextmgr + A context manager that will sign out of the server upon exit. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> # create an auth object + >>> tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') + + >>> # create an instance for your server + >>> server = TSC.Server('https://SERVER_URL') + + >>> # call the sign-in method with the auth object + >>> server.auth.sign_in(tableau_auth) """ - url = "{0}/{1}".format(self.baseurl, "signin") + url = f"{self.baseurl}/signin" signin_req = RequestFactory.Auth.signin_req(auth_req) server_response = self.parent_srv.session.post( url, data=signin_req, **self.parent_srv.http_options, allow_redirects=False @@ -63,22 +87,25 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr: user_id = parsed_response.find(".//t:user", namespaces=self.parent_srv.namespace).get("id", None) auth_token = parsed_response.find("t:credentials", namespaces=self.parent_srv.namespace).get("token", None) self.parent_srv._set_auth(site_id, user_id, auth_token) - logger.info("Signed into {0} as user with id {1}".format(self.parent_srv.server_address, user_id)) + logger.info(f"Signed into {self.parent_srv.server_address} as user with id {user_id}") return Auth.contextmgr(self.sign_out) # We use the same request that username/password login uses for all auth types. # The distinct methods are mostly useful for explicitly showing api version support for each auth type @api(version="3.6") def sign_in_with_personal_access_token(self, auth_req: "Credentials") -> contextmgr: + """Passthrough to sign_in method""" return self.sign_in(auth_req) @api(version="3.17") def sign_in_with_json_web_token(self, auth_req: "Credentials") -> contextmgr: + """Passthrough to sign_in method""" return self.sign_in(auth_req) @api(version="2.0") def sign_out(self) -> None: - url = "{0}/{1}".format(self.baseurl, "signout") + """Sign out of current session.""" + url = f"{self.baseurl}/signout" # If there are no auth tokens you're already signed out. No-op if not self.parent_srv.is_signed_in(): return @@ -88,7 +115,34 @@ def sign_out(self) -> None: @api(version="2.6") def switch_site(self, site_item: "SiteItem") -> contextmgr: - url = "{0}/{1}".format(self.baseurl, "switchSite") + """ + Switch to a different site on the server. This will sign out of the + current site and sign in to the new site. If used as a context manager, + will sign out of the new site upon exit. + + Parameters + ---------- + site_item : SiteItem + The site to switch to. + + Returns + ------- + contextmgr + A context manager that will sign out of the new site upon exit. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> # Find the site you want to switch to + >>> new_site = server.sites.get_by_id("9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d") + >>> # switch to the new site + >>> with server.auth.switch_site(new_site): + >>> # do something on the new site + >>> pass + + """ + url = f"{self.baseurl}/switchSite" switch_req = RequestFactory.Auth.switch_req(site_item.content_url) try: server_response = self.post_request(url, switch_req) @@ -104,11 +158,14 @@ def switch_site(self, site_item: "SiteItem") -> contextmgr: user_id = parsed_response.find(".//t:user", namespaces=self.parent_srv.namespace).get("id", None) auth_token = parsed_response.find("t:credentials", namespaces=self.parent_srv.namespace).get("token", None) self.parent_srv._set_auth(site_id, user_id, auth_token) - logger.info("Signed into {0} as user with id {1}".format(self.parent_srv.server_address, user_id)) + logger.info(f"Signed into {self.parent_srv.server_address} as user with id {user_id}") return Auth.contextmgr(self.sign_out) @api(version="3.10") def revoke_all_server_admin_tokens(self) -> None: - url = "{0}/{1}".format(self.baseurl, "revokeAllServerAdminTokens") + """ + Revokes all personal access tokens for all server admins on the server. + """ + url = f"{self.baseurl}/revokeAllServerAdminTokens" self.post_request(url, "") logger.info("Revoked all tokens for all server admins") diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index 57a5b0100..b02b05d78 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -1,15 +1,23 @@ import io import logging import os +from contextlib import closing from pathlib import Path -from typing import List, Optional, Tuple, Union +from typing import Optional, Union +from collections.abc import Iterator -from tableauserverclient.config import BYTES_PER_MB, FILESIZE_LIMIT_MB +from tableauserverclient.config import BYTES_PER_MB, config from tableauserverclient.filesys_helpers import get_file_object_size from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError from tableauserverclient.models import CustomViewItem, PaginationItem -from tableauserverclient.server import RequestFactory, RequestOptions, ImageRequestOptions +from tableauserverclient.server import ( + RequestFactory, + RequestOptions, + ImageRequestOptions, + PDFRequestOptions, + CSVRequestOptions, +) from tableauserverclient.helpers.logging import logger @@ -33,11 +41,11 @@ class CustomViews(QuerysetEndpoint[CustomViewItem]): def __init__(self, parent_srv): - super(CustomViews, self).__init__(parent_srv) + super().__init__(parent_srv) @property def baseurl(self) -> str: - return "{0}/sites/{1}/customviews".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/customviews" @property def expurl(self) -> str: @@ -55,7 +63,7 @@ def expurl(self) -> str: """ @api(version="3.18") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[CustomViewItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[CustomViewItem], PaginationItem]: logger.info("Querying all custom views on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -68,8 +76,8 @@ def get_by_id(self, view_id: str) -> Optional[CustomViewItem]: if not view_id: error = "Custom view item missing ID." raise MissingRequiredFieldError(error) - logger.info("Querying custom view (ID: {0})".format(view_id)) - url = "{0}/{1}".format(self.baseurl, view_id) + logger.info(f"Querying custom view (ID: {view_id})") + url = f"{self.baseurl}/{view_id}" server_response = self.get_request(url) return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace) @@ -83,17 +91,53 @@ def image_fetcher(): return self._get_view_image(view_item, req_options) view_item._set_image(image_fetcher) - logger.info("Populated image for custom view (ID: {0})".format(view_item.id)) + logger.info(f"Populated image for custom view (ID: {view_item.id})") def _get_view_image(self, view_item: CustomViewItem, req_options: Optional["ImageRequestOptions"]) -> bytes: - url = "{0}/{1}/image".format(self.baseurl, view_item.id) + url = f"{self.baseurl}/{view_item.id}/image" server_response = self.get_request(url, req_options) image = server_response.content return image - """ - Not yet implemented: pdf or csv exports - """ + @api(version="3.23") + def populate_pdf(self, custom_view_item: CustomViewItem, req_options: Optional["PDFRequestOptions"] = None) -> None: + if not custom_view_item.id: + error = "Custom View item missing ID." + raise MissingRequiredFieldError(error) + + def pdf_fetcher(): + return self._get_custom_view_pdf(custom_view_item, req_options) + + custom_view_item._set_pdf(pdf_fetcher) + logger.info(f"Populated pdf for custom view (ID: {custom_view_item.id})") + + def _get_custom_view_pdf( + self, custom_view_item: CustomViewItem, req_options: Optional["PDFRequestOptions"] + ) -> bytes: + url = f"{self.baseurl}/{custom_view_item.id}/pdf" + server_response = self.get_request(url, req_options) + pdf = server_response.content + return pdf + + @api(version="3.23") + def populate_csv(self, custom_view_item: CustomViewItem, req_options: Optional["CSVRequestOptions"] = None) -> None: + if not custom_view_item.id: + error = "Custom View item missing ID." + raise MissingRequiredFieldError(error) + + def csv_fetcher(): + return self._get_custom_view_csv(custom_view_item, req_options) + + custom_view_item._set_csv(csv_fetcher) + logger.info(f"Populated csv for custom view (ID: {custom_view_item.id})") + + def _get_custom_view_csv( + self, custom_view_item: CustomViewItem, req_options: Optional["CSVRequestOptions"] + ) -> Iterator[bytes]: + url = f"{self.baseurl}/{custom_view_item.id}/data" + + with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: + yield from server_response.iter_content(1024) @api(version="3.18") def update(self, view_item: CustomViewItem) -> Optional[CustomViewItem]: @@ -105,10 +149,10 @@ def update(self, view_item: CustomViewItem) -> Optional[CustomViewItem]: return view_item # Update the custom view owner or name - url = "{0}/{1}".format(self.baseurl, view_item.id) + url = f"{self.baseurl}/{view_item.id}" update_req = RequestFactory.CustomView.update_req(view_item) server_response = self.put_request(url, update_req) - logger.info("Updated custom view (ID: {0})".format(view_item.id)) + logger.info(f"Updated custom view (ID: {view_item.id})") return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace) # Delete 1 view by id @@ -117,9 +161,9 @@ def delete(self, view_id: str) -> None: if not view_id: error = "Custom View ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, view_id) + url = f"{self.baseurl}/{view_id}" self.delete_request(url) - logger.info("Deleted single custom view (ID: {0})".format(view_id)) + logger.info(f"Deleted single custom view (ID: {view_id})") @api(version="3.21") def download(self, view_item: CustomViewItem, file: PathOrFileW) -> PathOrFileW: @@ -144,7 +188,7 @@ def publish(self, view_item: CustomViewItem, file: PathOrFileR) -> Optional[Cust else: raise ValueError("File path or file object required for publishing custom view.") - if size >= FILESIZE_LIMIT_MB * BYTES_PER_MB: + if size >= config.FILESIZE_LIMIT_MB * BYTES_PER_MB: upload_session_id = self.parent_srv.fileuploads.upload(file) url = f"{url}?uploadSessionId={upload_session_id}" xml_request, content_type = RequestFactory.CustomView.publish_req_chunked(view_item) diff --git a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py index 256a6e766..579001156 100644 --- a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py +++ b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py @@ -10,14 +10,14 @@ class DataAccelerationReport(Endpoint): def __init__(self, parent_srv): - super(DataAccelerationReport, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl) @property def baseurl(self): - return "{0}/sites/{1}/dataAccelerationReport".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/dataAccelerationReport" @api(version="3.8") def get(self, req_options=None): diff --git a/tableauserverclient/server/endpoint/data_alert_endpoint.py b/tableauserverclient/server/endpoint/data_alert_endpoint.py index fd02d2e4a..ba3ecd74f 100644 --- a/tableauserverclient/server/endpoint/data_alert_endpoint.py +++ b/tableauserverclient/server/endpoint/data_alert_endpoint.py @@ -7,7 +7,7 @@ from tableauserverclient.helpers.logging import logger -from typing import List, Optional, TYPE_CHECKING, Tuple, Union +from typing import Optional, TYPE_CHECKING, Union if TYPE_CHECKING: @@ -17,14 +17,14 @@ class DataAlerts(Endpoint): def __init__(self, parent_srv: "Server") -> None: - super(DataAlerts, self).__init__(parent_srv) + super().__init__(parent_srv) @property def baseurl(self) -> str: - return "{0}/sites/{1}/dataAlerts".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/dataAlerts" @api(version="3.2") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[DataAlertItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[DataAlertItem], PaginationItem]: logger.info("Querying all dataAlerts on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -38,8 +38,8 @@ def get_by_id(self, dataAlert_id: str) -> DataAlertItem: if not dataAlert_id: error = "dataAlert ID undefined." raise ValueError(error) - logger.info("Querying single dataAlert (ID: {0})".format(dataAlert_id)) - url = "{0}/{1}".format(self.baseurl, dataAlert_id) + logger.info(f"Querying single dataAlert (ID: {dataAlert_id})") + url = f"{self.baseurl}/{dataAlert_id}" server_response = self.get_request(url) return DataAlertItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -55,9 +55,9 @@ def delete(self, dataAlert: Union[DataAlertItem, str]) -> None: error = "Dataalert ID undefined." raise ValueError(error) # DELETE /api/api-version/sites/site-id/dataAlerts/data-alert-id/users/user-id - url = "{0}/{1}".format(self.baseurl, dataAlert_id) + url = f"{self.baseurl}/{dataAlert_id}" self.delete_request(url) - logger.info("Deleted single dataAlert (ID: {0})".format(dataAlert_id)) + logger.info(f"Deleted single dataAlert (ID: {dataAlert_id})") @api(version="3.2") def delete_user_from_alert(self, dataAlert: Union[DataAlertItem, str], user: Union[UserItem, str]) -> None: @@ -80,9 +80,9 @@ def delete_user_from_alert(self, dataAlert: Union[DataAlertItem, str], user: Uni error = "User ID undefined." raise ValueError(error) # DELETE /api/api-version/sites/site-id/dataAlerts/data-alert-id/users/user-id - url = "{0}/{1}/users/{2}".format(self.baseurl, dataAlert_id, user_id) + url = f"{self.baseurl}/{dataAlert_id}/users/{user_id}" self.delete_request(url) - logger.info("Deleted User (ID {0}) from dataAlert (ID: {1})".format(user_id, dataAlert_id)) + logger.info(f"Deleted User (ID {user_id}) from dataAlert (ID: {dataAlert_id})") @api(version="3.2") def add_user_to_alert(self, dataAlert_item: DataAlertItem, user: Union[UserItem, str]) -> UserItem: @@ -98,10 +98,10 @@ def add_user_to_alert(self, dataAlert_item: DataAlertItem, user: Union[UserItem, if not user_id: error = "User ID undefined." raise ValueError(error) - url = "{0}/{1}/users".format(self.baseurl, dataAlert_item.id) + url = f"{self.baseurl}/{dataAlert_item.id}/users" update_req = RequestFactory.DataAlert.add_user_to_alert(dataAlert_item, user_id) server_response = self.post_request(url, update_req) - logger.info("Added user (ID {0}) to dataAlert item (ID: {1})".format(user_id, dataAlert_item.id)) + logger.info(f"Added user (ID {user_id}) to dataAlert item (ID: {dataAlert_item.id})") added_user = UserItem.from_response(server_response.content, self.parent_srv.namespace)[0] return added_user @@ -111,9 +111,9 @@ def update(self, dataAlert_item: DataAlertItem) -> DataAlertItem: error = "Dataalert item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}".format(self.baseurl, dataAlert_item.id) + url = f"{self.baseurl}/{dataAlert_item.id}" update_req = RequestFactory.DataAlert.update_req(dataAlert_item) server_response = self.put_request(url, update_req) - logger.info("Updated dataAlert item (ID: {0})".format(dataAlert_item.id)) + logger.info(f"Updated dataAlert item (ID: {dataAlert_item.id})") updated_dataAlert = DataAlertItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_dataAlert diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index 2f8fece07..c0e106eb2 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -1,5 +1,6 @@ import logging -from typing import Union, Iterable, Set +from typing import Union +from collections.abc import Iterable from tableauserverclient.server.endpoint.default_permissions_endpoint import _DefaultPermissionsEndpoint from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint @@ -15,7 +16,7 @@ class Databases(Endpoint, TaggingMixin): def __init__(self, parent_srv): - super(Databases, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl) @@ -23,7 +24,7 @@ def __init__(self, parent_srv): @property def baseurl(self): - return "{0}/sites/{1}/databases".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/databases" @api(version="3.5") def get(self, req_options=None): @@ -40,8 +41,8 @@ def get_by_id(self, database_id): if not database_id: error = "database ID undefined." raise ValueError(error) - logger.info("Querying single database (ID: {0})".format(database_id)) - url = "{0}/{1}".format(self.baseurl, database_id) + logger.info(f"Querying single database (ID: {database_id})") + url = f"{self.baseurl}/{database_id}" server_response = self.get_request(url) return DatabaseItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -50,9 +51,9 @@ def delete(self, database_id): if not database_id: error = "Database ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, database_id) + url = f"{self.baseurl}/{database_id}" self.delete_request(url) - logger.info("Deleted single database (ID: {0})".format(database_id)) + logger.info(f"Deleted single database (ID: {database_id})") @api(version="3.5") def update(self, database_item): @@ -60,10 +61,10 @@ def update(self, database_item): error = "Database item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}".format(self.baseurl, database_item.id) + url = f"{self.baseurl}/{database_item.id}" update_req = RequestFactory.Database.update_req(database_item) server_response = self.put_request(url, update_req) - logger.info("Updated database item (ID: {0})".format(database_item.id)) + logger.info(f"Updated database item (ID: {database_item.id})") updated_database = DatabaseItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_database @@ -78,10 +79,10 @@ def column_fetcher(): return self._get_tables_for_database(database_item) database_item._set_tables(column_fetcher) - logger.info("Populated tables for database (ID: {0}".format(database_item.id)) + logger.info(f"Populated tables for database (ID: {database_item.id}") def _get_tables_for_database(self, database_item): - url = "{0}/{1}/tables".format(self.baseurl, database_item.id) + url = f"{self.baseurl}/{database_item.id}/tables" server_response = self.get_request(url) tables = TableItem.from_response(server_response.content, self.parent_srv.namespace) return tables @@ -127,7 +128,7 @@ def delete_dqw(self, item): self._data_quality_warnings.clear(item) @api(version="3.9") - def add_tags(self, item: Union[DatabaseItem, str], tags: Iterable[str]) -> Set[str]: + def add_tags(self, item: Union[DatabaseItem, str], tags: Iterable[str]) -> set[str]: return super().add_tags(item, tags) @api(version="3.9") diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 7f3a47075..6bd809c28 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -6,7 +6,8 @@ from contextlib import closing from pathlib import Path -from typing import Iterable, List, Mapping, Optional, Sequence, Set, Tuple, TYPE_CHECKING, Union +from typing import Optional, TYPE_CHECKING, Union +from collections.abc import Iterable, Mapping, Sequence from tableauserverclient.helpers.headers import fix_filename from tableauserverclient.server.query import QuerySet @@ -22,7 +23,7 @@ from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin -from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, FILESIZE_LIMIT_MB, BYTES_PER_MB, config +from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, BYTES_PER_MB, config from tableauserverclient.filesys_helpers import ( make_download_path, get_file_type, @@ -57,7 +58,7 @@ class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]): def __init__(self, parent_srv: "Server") -> None: - super(Datasources, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "datasource") @@ -65,11 +66,11 @@ def __init__(self, parent_srv: "Server") -> None: @property def baseurl(self) -> str: - return "{0}/sites/{1}/datasources".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/datasources" # Get all datasources @api(version="2.0") - def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[DatasourceItem], PaginationItem]: + def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[DatasourceItem], PaginationItem]: logger.info("Querying all datasources on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -83,8 +84,8 @@ def get_by_id(self, datasource_id: str) -> DatasourceItem: if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) - logger.info("Querying single datasource (ID: {0})".format(datasource_id)) - url = "{0}/{1}".format(self.baseurl, datasource_id) + logger.info(f"Querying single datasource (ID: {datasource_id})") + url = f"{self.baseurl}/{datasource_id}" server_response = self.get_request(url) return DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -99,10 +100,10 @@ def connections_fetcher(): return self._get_datasource_connections(datasource_item) datasource_item._set_connections(connections_fetcher) - logger.info("Populated connections for datasource (ID: {0})".format(datasource_item.id)) + logger.info(f"Populated connections for datasource (ID: {datasource_item.id})") def _get_datasource_connections(self, datasource_item, req_options=None): - url = "{0}/{1}/connections".format(self.baseurl, datasource_item.id) + url = f"{self.baseurl}/{datasource_item.id}/connections" server_response = self.get_request(url, req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) return connections @@ -113,9 +114,9 @@ def delete(self, datasource_id: str) -> None: if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, datasource_id) + url = f"{self.baseurl}/{datasource_id}" self.delete_request(url) - logger.info("Deleted single datasource (ID: {0})".format(datasource_id)) + logger.info(f"Deleted single datasource (ID: {datasource_id})") # Download 1 datasource by id @api(version="2.0") @@ -152,11 +153,11 @@ def update(self, datasource_item: DatasourceItem) -> DatasourceItem: self.update_tags(datasource_item) # Update the datasource itself - url = "{0}/{1}".format(self.baseurl, datasource_item.id) + url = f"{self.baseurl}/{datasource_item.id}" update_req = RequestFactory.Datasource.update_req(datasource_item) server_response = self.put_request(url, update_req) - logger.info("Updated datasource item (ID: {0})".format(datasource_item.id)) + logger.info(f"Updated datasource item (ID: {datasource_item.id})") updated_datasource = copy.copy(datasource_item) return updated_datasource._parse_common_elements(server_response.content, self.parent_srv.namespace) @@ -165,7 +166,7 @@ def update(self, datasource_item: DatasourceItem) -> DatasourceItem: def update_connection( self, datasource_item: DatasourceItem, connection_item: ConnectionItem ) -> Optional[ConnectionItem]: - url = "{0}/{1}/connections/{2}".format(self.baseurl, datasource_item.id, connection_item.id) + url = f"{self.baseurl}/{datasource_item.id}/connections/{connection_item.id}" update_req = RequestFactory.Connection.update_req(connection_item) server_response = self.put_request(url, update_req) @@ -174,18 +175,16 @@ def update_connection( return None if len(connections) > 1: - logger.debug("Multiple connections returned ({0})".format(len(connections))) + logger.debug(f"Multiple connections returned ({len(connections)})") connection = list(filter(lambda x: x.id == connection_item.id, connections))[0] - logger.info( - "Updated datasource item (ID: {0} & connection item {1}".format(datasource_item.id, connection_item.id) - ) + logger.info(f"Updated datasource item (ID: {datasource_item.id} & connection item {connection_item.id}") return connection @api(version="2.8") def refresh(self, datasource_item: DatasourceItem) -> JobItem: id_ = getattr(datasource_item, "id", datasource_item) - url = "{0}/{1}/refresh".format(self.baseurl, id_) + url = f"{self.baseurl}/{id_}/refresh" empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -194,7 +193,7 @@ def refresh(self, datasource_item: DatasourceItem) -> JobItem: @api(version="3.5") def create_extract(self, datasource_item: DatasourceItem, encrypt: bool = False) -> JobItem: id_ = getattr(datasource_item, "id", datasource_item) - url = "{0}/{1}/createExtract?encrypt={2}".format(self.baseurl, id_, encrypt) + url = f"{self.baseurl}/{id_}/createExtract?encrypt={encrypt}" empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -203,7 +202,7 @@ def create_extract(self, datasource_item: DatasourceItem, encrypt: bool = False) @api(version="3.5") def delete_extract(self, datasource_item: DatasourceItem) -> None: id_ = getattr(datasource_item, "id", datasource_item) - url = "{0}/{1}/deleteExtract".format(self.baseurl, id_) + url = f"{self.baseurl}/{id_}/deleteExtract" empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) @@ -223,12 +222,12 @@ def publish( if isinstance(file, (os.PathLike, str)): if not os.path.isfile(file): error = "File path does not lead to an existing file." - raise IOError(error) + raise OSError(error) filename = os.path.basename(file) file_extension = os.path.splitext(filename)[1][1:] file_size = os.path.getsize(file) - logger.debug("Publishing file `{}`, size `{}`".format(filename, file_size)) + logger.debug(f"Publishing file `{filename}`, size `{file_size}`") # If name is not defined, grab the name from the file to publish if not datasource_item.name: datasource_item.name = os.path.splitext(filename)[0] @@ -247,10 +246,10 @@ def publish( elif file_type == "xml": file_extension = "tds" else: - error = "Unsupported file type {}".format(file_type) + error = f"Unsupported file type {file_type}" raise ValueError(error) - filename = "{}.{}".format(datasource_item.name, file_extension) + filename = f"{datasource_item.name}.{file_extension}" file_size = get_file_object_size(file) else: @@ -261,27 +260,27 @@ def publish( raise ValueError(error) # Construct the url with the defined mode - url = "{0}?datasourceType={1}".format(self.baseurl, file_extension) + url = f"{self.baseurl}?datasourceType={file_extension}" if mode == self.parent_srv.PublishMode.Overwrite or mode == self.parent_srv.PublishMode.Append: - url += "&{0}=true".format(mode.lower()) + url += f"&{mode.lower()}=true" if as_job: - url += "&{0}=true".format("asJob") + url += "&{}=true".format("asJob") # Determine if chunking is required (64MB is the limit for single upload method) - if file_size >= FILESIZE_LIMIT_MB * BYTES_PER_MB: + if file_size >= config.FILESIZE_LIMIT_MB * BYTES_PER_MB: logger.info( "Publishing {} to server with chunking method (datasource over {}MB, chunk size {}MB)".format( - filename, FILESIZE_LIMIT_MB, config.CHUNK_SIZE_MB + filename, config.FILESIZE_LIMIT_MB, config.CHUNK_SIZE_MB ) ) upload_session_id = self.parent_srv.fileuploads.upload(file) - url = "{0}&uploadSessionId={1}".format(url, upload_session_id) + url = f"{url}&uploadSessionId={upload_session_id}" xml_request, content_type = RequestFactory.Datasource.publish_req_chunked( datasource_item, connection_credentials, connections ) else: - logger.info("Publishing {0} to server".format(filename)) + logger.info(f"Publishing {filename} to server") if isinstance(file, (Path, str)): with open(file, "rb") as f: @@ -309,11 +308,11 @@ def publish( if as_job: new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Published {0} (JOB_ID: {1}".format(filename, new_job.id)) + logger.info(f"Published {filename} (JOB_ID: {new_job.id}") return new_job else: new_datasource = DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Published {0} (ID: {1})".format(filename, new_datasource.id)) + logger.info(f"Published {filename} (ID: {new_datasource.id})") return new_datasource @api(version="3.13") @@ -327,23 +326,23 @@ def update_hyper_data( ) -> JobItem: if isinstance(datasource_or_connection_item, DatasourceItem): datasource_id = datasource_or_connection_item.id - url = "{0}/{1}/data".format(self.baseurl, datasource_id) + url = f"{self.baseurl}/{datasource_id}/data" elif isinstance(datasource_or_connection_item, ConnectionItem): datasource_id = datasource_or_connection_item.datasource_id connection_id = datasource_or_connection_item.id - url = "{0}/{1}/connections/{2}/data".format(self.baseurl, datasource_id, connection_id) + url = f"{self.baseurl}/{datasource_id}/connections/{connection_id}/data" else: assert isinstance(datasource_or_connection_item, str) - url = "{0}/{1}/data".format(self.baseurl, datasource_or_connection_item) + url = f"{self.baseurl}/{datasource_or_connection_item}/data" if payload is not None: if not os.path.isfile(payload): error = "File path does not lead to an existing file." - raise IOError(error) + raise OSError(error) - logger.info("Uploading {0} to server with chunking method for Update job".format(payload)) + logger.info(f"Uploading {payload} to server with chunking method for Update job") upload_session_id = self.parent_srv.fileuploads.upload(payload) - url = "{0}?uploadSessionId={1}".format(url, upload_session_id) + url = f"{url}?uploadSessionId={upload_session_id}" json_request = json.dumps({"actions": actions}) parameters = {"headers": {"requestid": request_id}} @@ -356,7 +355,7 @@ def populate_permissions(self, item: DatasourceItem) -> None: self._permissions.populate(item) @api(version="2.0") - def update_permissions(self, item: DatasourceItem, permission_item: List["PermissionsRule"]) -> None: + def update_permissions(self, item: DatasourceItem, permission_item: list["PermissionsRule"]) -> None: self._permissions.update(item, permission_item) @api(version="2.0") @@ -390,12 +389,12 @@ def revisions_fetcher(): return self._get_datasource_revisions(datasource_item) datasource_item._set_revisions(revisions_fetcher) - logger.info("Populated revisions for datasource (ID: {0})".format(datasource_item.id)) + logger.info(f"Populated revisions for datasource (ID: {datasource_item.id})") def _get_datasource_revisions( self, datasource_item: DatasourceItem, req_options: Optional["RequestOptions"] = None - ) -> List[RevisionItem]: - url = "{0}/{1}/revisions".format(self.baseurl, datasource_item.id) + ) -> list[RevisionItem]: + url = f"{self.baseurl}/{datasource_item.id}/revisions" server_response = self.get_request(url, req_options) revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, datasource_item) return revisions @@ -413,9 +412,9 @@ def download_revision( error = "Datasource ID undefined." raise ValueError(error) if revision_number is None: - url = "{0}/{1}/content".format(self.baseurl, datasource_id) + url = f"{self.baseurl}/{datasource_id}/content" else: - url = "{0}/{1}/revisions/{2}/content".format(self.baseurl, datasource_id, revision_number) + url = f"{self.baseurl}/{datasource_id}/revisions/{revision_number}/content" if not include_extract: url += "?includeExtract=False" @@ -437,9 +436,7 @@ def download_revision( f.write(chunk) return_path = os.path.abspath(download_path) - logger.info( - "Downloaded datasource revision {0} to {1} (ID: {2})".format(revision_number, return_path, datasource_id) - ) + logger.info(f"Downloaded datasource revision {revision_number} to {return_path} (ID: {datasource_id})") return return_path @api(version="2.3") @@ -449,19 +446,17 @@ def delete_revision(self, datasource_id: str, revision_number: str) -> None: url = "/".join([self.baseurl, datasource_id, "revisions", revision_number]) self.delete_request(url) - logger.info( - "Deleted single datasource revision (ID: {0}) (Revision: {1})".format(datasource_id, revision_number) - ) + logger.info(f"Deleted single datasource revision (ID: {datasource_id}) (Revision: {revision_number})") # a convenience method @api(version="2.8") def schedule_extract_refresh( self, schedule_id: str, item: DatasourceItem - ) -> List["AddResponse"]: # actually should return a task + ) -> list["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, datasource=item) @api(version="1.0") - def add_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + def add_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> set[str]: return super().add_tags(item, tags) @api(version="1.0") diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py index 19112d713..499324e8e 100644 --- a/tableauserverclient/server/endpoint/default_permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py @@ -4,7 +4,8 @@ from .exceptions import MissingRequiredFieldError from tableauserverclient.server import RequestFactory from tableauserverclient.models import DatabaseItem, PermissionsRule, ProjectItem, plural_type, Resource -from typing import TYPE_CHECKING, Callable, List, Optional, Sequence, Union +from typing import TYPE_CHECKING, Callable, Optional, Union +from collections.abc import Sequence if TYPE_CHECKING: from ..server import Server @@ -25,7 +26,7 @@ class _DefaultPermissionsEndpoint(Endpoint): """ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> None: - super(_DefaultPermissionsEndpoint, self).__init__(parent_srv) + super().__init__(parent_srv) # owner_baseurl is the baseurl of the parent, a project or database. # It MUST be a lambda since we don't know the full site URL until we sign in. @@ -33,23 +34,25 @@ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> No self.owner_baseurl = owner_baseurl def __str__(self): - return "".format(self.owner_baseurl()) + return f"" __repr__ = __str__ def update_default_permissions( - self, resource: BaseItem, permissions: Sequence[PermissionsRule], content_type: Resource - ) -> List[PermissionsRule]: - url = "{0}/{1}/default-permissions/{2}".format(self.owner_baseurl(), resource.id, plural_type(content_type)) + self, resource: BaseItem, permissions: Sequence[PermissionsRule], content_type: Union[Resource, str] + ) -> list[PermissionsRule]: + url = f"{self.owner_baseurl()}/{resource.id}/default-permissions/{plural_type(content_type)}" update_req = RequestFactory.Permission.add_req(permissions) response = self.put_request(url, update_req) permissions = PermissionsRule.from_response(response.content, self.parent_srv.namespace) - logger.info("Updated default {} permissions for resource {}".format(content_type, resource.id)) + logger.info(f"Updated default {content_type} permissions for resource {resource.id}") logger.info(permissions) return permissions - def delete_default_permission(self, resource: BaseItem, rule: PermissionsRule, content_type: Resource) -> None: + def delete_default_permission( + self, resource: BaseItem, rule: PermissionsRule, content_type: Union[Resource, str] + ) -> None: for capability, mode in rule.capabilities.items(): # Made readability better but line is too long, will make this look better url = ( @@ -65,29 +68,27 @@ def delete_default_permission(self, resource: BaseItem, rule: PermissionsRule, c ) ) - logger.debug("Removing {0} permission for capability {1}".format(mode, capability)) + logger.debug(f"Removing {mode} permission for capability {capability}") self.delete_request(url) - logger.info( - "Deleted permission for {0} {1} item {2}".format(rule.grantee.tag_name, rule.grantee.id, resource.id) - ) + logger.info(f"Deleted permission for {rule.grantee.tag_name} {rule.grantee.id} item {resource.id}") - def populate_default_permissions(self, item: BaseItem, content_type: Resource) -> None: + def populate_default_permissions(self, item: BaseItem, content_type: Union[Resource, str]) -> None: if not item.id: error = "Server item is missing ID. Item must be retrieved from server first." raise MissingRequiredFieldError(error) - def permission_fetcher() -> List[PermissionsRule]: + def permission_fetcher() -> list[PermissionsRule]: return self._get_default_permissions(item, content_type) item._set_default_permissions(permission_fetcher, content_type) - logger.info("Populated default {0} permissions for item (ID: {1})".format(content_type, item.id)) + logger.info(f"Populated default {content_type} permissions for item (ID: {item.id})") def _get_default_permissions( - self, item: BaseItem, content_type: Resource, req_options: Optional["RequestOptions"] = None - ) -> List[PermissionsRule]: - url = "{0}/{1}/default-permissions/{2}".format(self.owner_baseurl(), item.id, plural_type(content_type)) + self, item: BaseItem, content_type: Union[Resource, str], req_options: Optional["RequestOptions"] = None + ) -> list[PermissionsRule]: + url = f"{self.owner_baseurl()}/{item.id}/default-permissions/{plural_type(content_type)}" server_response = self.get_request(url, req_options) permissions = PermissionsRule.from_response(server_response.content, self.parent_srv.namespace) logger.info({"content_type": content_type, "permissions": permissions}) diff --git a/tableauserverclient/server/endpoint/dqw_endpoint.py b/tableauserverclient/server/endpoint/dqw_endpoint.py index 5296523ee..90e31483b 100644 --- a/tableauserverclient/server/endpoint/dqw_endpoint.py +++ b/tableauserverclient/server/endpoint/dqw_endpoint.py @@ -10,35 +10,35 @@ class _DataQualityWarningEndpoint(Endpoint): def __init__(self, parent_srv, resource_type): - super(_DataQualityWarningEndpoint, self).__init__(parent_srv) + super().__init__(parent_srv) self.resource_type = resource_type @property def baseurl(self): - return "{0}/sites/{1}/dataQualityWarnings/{2}".format( + return "{}/sites/{}/dataQualityWarnings/{}".format( self.parent_srv.baseurl, self.parent_srv.site_id, self.resource_type ) def add(self, resource, warning): - url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=resource.id) + url = f"{self.baseurl}/{resource.id}" add_req = RequestFactory.DQW.add_req(warning) response = self.post_request(url, add_req) warnings = DQWItem.from_response(response.content, self.parent_srv.namespace) - logger.info("Added dqw for resource {0}".format(resource.id)) + logger.info(f"Added dqw for resource {resource.id}") return warnings def update(self, resource, warning): - url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=resource.id) + url = f"{self.baseurl}/{resource.id}" add_req = RequestFactory.DQW.update_req(warning) response = self.put_request(url, add_req) warnings = DQWItem.from_response(response.content, self.parent_srv.namespace) - logger.info("Added dqw for resource {0}".format(resource.id)) + logger.info(f"Added dqw for resource {resource.id}") return warnings def clear(self, resource): - url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=resource.id) + url = f"{self.baseurl}/{resource.id}" return self.delete_request(url) def populate(self, item): @@ -50,10 +50,10 @@ def dqw_fetcher(): return self._get_data_quality_warnings(item) item._set_data_quality_warnings(dqw_fetcher) - logger.info("Populated permissions for item (ID: {0})".format(item.id)) + logger.info(f"Populated permissions for item (ID: {item.id})") def _get_data_quality_warnings(self, item, req_options=None): - url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=item.id) + url = f"{self.baseurl}/{item.id}" server_response = self.get_request(url, req_options) dqws = DQWItem.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index be0602df5..9e1160705 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -8,12 +8,9 @@ from typing import ( Any, Callable, - Dict, Generic, - List, Optional, TYPE_CHECKING, - Tuple, TypeVar, Union, ) @@ -22,6 +19,7 @@ from tableauserverclient.server.request_options import RequestOptions from tableauserverclient.server.endpoint.exceptions import ( + FailedSignInError, ServerResponseError, InternalServerError, NonXMLResponseError, @@ -56,7 +54,7 @@ def __init__(self, parent_srv: "Server"): async_response = None @staticmethod - def set_parameters(http_options, auth_token, content, content_type, parameters) -> Dict[str, Any]: + def set_parameters(http_options, auth_token, content, content_type, parameters) -> dict[str, Any]: parameters = parameters or {} parameters.update(http_options) if "headers" not in parameters: @@ -82,7 +80,7 @@ def set_user_agent(parameters): else: # only set the TSC user agent if not already populated _client_version: Optional[str] = get_versions()["version"] - parameters["headers"][USER_AGENT_HEADER] = "Tableau Server Client/{}".format(_client_version) + parameters["headers"][USER_AGENT_HEADER] = f"Tableau Server Client/{_client_version}" # result: parameters["headers"]["User-Agent"] is set # return explicitly for testing only @@ -90,12 +88,12 @@ def set_user_agent(parameters): def _blocking_request(self, method, url, parameters={}) -> Optional[Union["Response", Exception]]: response = None - logger.debug("[{}] Begin blocking request to {}".format(datetime.timestamp(), url)) + logger.debug(f"[{datetime.timestamp()}] Begin blocking request to {url}") try: response = method(url, **parameters) - logger.debug("[{}] Call finished".format(datetime.timestamp())) + logger.debug(f"[{datetime.timestamp()}] Call finished") except Exception as e: - logger.debug("Error making request to server: {}".format(e)) + logger.debug(f"Error making request to server: {e}") raise e return response @@ -111,13 +109,13 @@ def _make_request( content: Optional[bytes] = None, auth_token: Optional[str] = None, content_type: Optional[str] = None, - parameters: Optional[Dict[str, Any]] = None, + parameters: Optional[dict[str, Any]] = None, ) -> "Response": parameters = Endpoint.set_parameters( self.parent_srv.http_options, auth_token, content, content_type, parameters ) - logger.debug("request method {}, url: {}".format(method.__name__, url)) + logger.debug(f"request method {method.__name__}, url: {url}") if content: redacted = helpers.strings.redact_xml(content[:200]) # this needs to be under a trace or something, it's a LOT @@ -129,21 +127,21 @@ def _make_request( server_response: Optional[Union["Response", Exception]] = self.send_request_while_show_progress_threaded( method, url, parameters, request_timeout ) - logger.debug("[{}] Async request returned: received {}".format(datetime.timestamp(), server_response)) + logger.debug(f"[{datetime.timestamp()}] Async request returned: received {server_response}") # is this blocking retry really necessary? I guess if it was just the threading messing it up? if server_response is None: logger.debug(server_response) - logger.debug("[{}] Async request failed: retrying".format(datetime.timestamp())) + logger.debug(f"[{datetime.timestamp()}] Async request failed: retrying") server_response = self._blocking_request(method, url, parameters) if server_response is None: - logger.debug("[{}] Request failed".format(datetime.timestamp())) + logger.debug(f"[{datetime.timestamp()}] Request failed") raise RuntimeError if isinstance(server_response, Exception): raise server_response self._check_status(server_response, url) loggable_response = self.log_response_safely(server_response) - logger.debug("Server response from {0}".format(url)) + logger.debug(f"Server response from {url}") # uncomment the following to log full responses in debug mode # BE CAREFUL WHEN SHARING THESE RESULTS - MAY CONTAIN YOUR SENSITIVE DATA # logger.debug(loggable_response) @@ -154,16 +152,16 @@ def _make_request( return server_response def _check_status(self, server_response: "Response", url: Optional[str] = None): - logger.debug("Response status: {}".format(server_response)) + logger.debug(f"Response status: {server_response}") if not hasattr(server_response, "status_code"): - raise EnvironmentError("Response is not a http response?") + raise OSError("Response is not a http response?") if server_response.status_code >= 500: raise InternalServerError(server_response, url) elif server_response.status_code not in Success_codes: try: if server_response.status_code == 401: # TODO: catch this in server.py and attempt to sign in again, in case it's a session expiry - raise NotSignedInError(server_response.content, url) + raise FailedSignInError.from_response(server_response.content, self.parent_srv.namespace, url) raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace, url) except ParseError: @@ -183,9 +181,9 @@ def log_response_safely(self, server_response: "Response") -> str: # content-type is an octet-stream accomplishes the same goal without eagerly loading content. # This check is to determine if the response is a text response (xml or otherwise) # so that we do not attempt to log bytes and other binary data. - loggable_response = "Content type `{}`".format(content_type) + loggable_response = f"Content type `{content_type}`" if content_type == "application/octet-stream": - loggable_response = "A stream of type {} [Truncated File Contents]".format(content_type) + loggable_response = f"A stream of type {content_type} [Truncated File Contents]" elif server_response.encoding and len(server_response.content) > 0: loggable_response = helpers.strings.redact_xml(server_response.content.decode(server_response.encoding)) return loggable_response @@ -313,7 +311,7 @@ def wrapper(self: E, *args: P.args, **kwargs: P.kwargs) -> R: for p in params_to_check: min_ver = Version(str(params[p])) if server_ver < min_ver: - error = "{!r} not available in {}, it will be ignored. Added in {}".format(p, server_ver, min_ver) + error = f"{p!r} not available in {server_ver}, it will be ignored. Added in {min_ver}" warnings.warn(error) return func(self, *args, **kwargs) @@ -353,5 +351,5 @@ def paginate(self, **kwargs) -> QuerySet[T]: return queryset @abc.abstractmethod - def get(self, request_options: Optional[RequestOptions] = None) -> Tuple[List[T], PaginationItem]: + def get(self, request_options: Optional[RequestOptions] = None) -> tuple[list[T], PaginationItem]: raise NotImplementedError(f".get has not been implemented for {self.__class__.__qualname__}") diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 9dfd38da6..77332da3e 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -1,24 +1,31 @@ from defusedxml.ElementTree import fromstring -from typing import Optional +from typing import Mapping, Optional, TypeVar + + +def split_pascal_case(s: str) -> str: + return "".join([f" {c}" if c.isupper() else c for c in s]).strip() class TableauError(Exception): pass -class ServerResponseError(TableauError): - def __init__(self, code, summary, detail, url=None): +T = TypeVar("T") + + +class XMLError(TableauError): + def __init__(self, code: str, summary: str, detail: str, url: Optional[str] = None) -> None: self.code = code self.summary = summary self.detail = detail self.url = url - super(ServerResponseError, self).__init__(str(self)) + super().__init__(str(self)) def __str__(self): - return "\n\n\t{0}: {1}\n\t\t{2}".format(self.code, self.summary, self.detail) + return f"\n\n\t{self.code}: {self.summary}\n\t\t{self.detail}" @classmethod - def from_response(cls, resp, ns, url=None): + def from_response(cls, resp, ns, url): # Check elements exist before .text parsed_response = fromstring(resp) try: @@ -33,6 +40,10 @@ def from_response(cls, resp, ns, url=None): return error_response +class ServerResponseError(XMLError): + pass + + class InternalServerError(TableauError): def __init__(self, server_response, request_url: Optional[str] = None): self.code = server_response.status_code @@ -40,7 +51,7 @@ def __init__(self, server_response, request_url: Optional[str] = None): self.url = request_url or "server" def __str__(self): - return "\n\nInternal error {0} at {1}\n{2}".format(self.code, self.url, self.content) + return f"\n\nInternal error {self.code} at {self.url}\n{self.content}" class MissingRequiredFieldError(TableauError): @@ -51,6 +62,11 @@ class NotSignedInError(TableauError): pass +class FailedSignInError(XMLError, NotSignedInError): + def __str__(self): + return f"{split_pascal_case(self.__class__.__name__)}: {super().__str__()}" + + class ItemTypeNotAllowed(TableauError): pass diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index 5f298f37e..8330e6d2c 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -20,13 +20,13 @@ class Favorites(Endpoint): @property def baseurl(self) -> str: - return "{0}/sites/{1}/favorites".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/favorites" # Gets all favorites @api(version="2.5") def get(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: - logger.info("Querying all favorites for user {0}".format(user_item.name)) - url = "{0}/{1}".format(self.baseurl, user_item.id) + logger.info(f"Querying all favorites for user {user_item.name}") + url = f"{self.baseurl}/{user_item.id}" server_response = self.get_request(url, req_options) user_item._favorites = FavoriteItem.from_response(server_response.content, self.parent_srv.namespace) @@ -34,53 +34,53 @@ def get(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) @api(version="3.15") def add_favorite(self, user_item: UserItem, content_type: str, item: TableauItem) -> "Response": - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_request(item.id, content_type, item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited {0} for user (ID: {1})".format(item.name, user_item.id)) + logger.info(f"Favorited {item.name} for user (ID: {user_item.id})") return server_response @api(version="2.0") def add_favorite_workbook(self, user_item: UserItem, workbook_item: WorkbookItem) -> None: - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_workbook_req(workbook_item.id, workbook_item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited {0} for user (ID: {1})".format(workbook_item.name, user_item.id)) + logger.info(f"Favorited {workbook_item.name} for user (ID: {user_item.id})") @api(version="2.0") def add_favorite_view(self, user_item: UserItem, view_item: ViewItem) -> None: - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_view_req(view_item.id, view_item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited {0} for user (ID: {1})".format(view_item.name, user_item.id)) + logger.info(f"Favorited {view_item.name} for user (ID: {user_item.id})") @api(version="2.3") def add_favorite_datasource(self, user_item: UserItem, datasource_item: DatasourceItem) -> None: - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_datasource_req(datasource_item.id, datasource_item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited {0} for user (ID: {1})".format(datasource_item.name, user_item.id)) + logger.info(f"Favorited {datasource_item.name} for user (ID: {user_item.id})") @api(version="3.1") def add_favorite_project(self, user_item: UserItem, project_item: ProjectItem) -> None: - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_project_req(project_item.id, project_item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited {0} for user (ID: {1})".format(project_item.name, user_item.id)) + logger.info(f"Favorited {project_item.name} for user (ID: {user_item.id})") @api(version="3.3") def add_favorite_flow(self, user_item: UserItem, flow_item: FlowItem) -> None: - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_flow_req(flow_item.id, flow_item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited {0} for user (ID: {1})".format(flow_item.name, user_item.id)) + logger.info(f"Favorited {flow_item.name} for user (ID: {user_item.id})") @api(version="3.3") def add_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> None: - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_request(metric_item.id, Resource.Metric, metric_item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited metric {0} for user (ID: {1})".format(metric_item.name, user_item.id)) + logger.info(f"Favorited metric {metric_item.name} for user (ID: {user_item.id})") # ------- delete from favorites # Response: @@ -94,42 +94,42 @@ def add_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> N @api(version="3.15") def delete_favorite(self, user_item: UserItem, content_type: Resource, item: TableauItem) -> None: - url = "{0}/{1}/{2}/{3}".format(self.baseurl, user_item.id, content_type, item.id) - logger.info("Removing favorite {0}({1}) for user (ID: {2})".format(content_type, item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/{content_type}/{item.id}" + logger.info(f"Removing favorite {content_type}({item.id}) for user (ID: {user_item.id})") self.delete_request(url) @api(version="2.0") def delete_favorite_workbook(self, user_item: UserItem, workbook_item: WorkbookItem) -> None: - url = "{0}/{1}/workbooks/{2}".format(self.baseurl, user_item.id, workbook_item.id) - logger.info("Removing favorite workbook {0} for user (ID: {1})".format(workbook_item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/workbooks/{workbook_item.id}" + logger.info(f"Removing favorite workbook {workbook_item.id} for user (ID: {user_item.id})") self.delete_request(url) @api(version="2.0") def delete_favorite_view(self, user_item: UserItem, view_item: ViewItem) -> None: - url = "{0}/{1}/views/{2}".format(self.baseurl, user_item.id, view_item.id) - logger.info("Removing favorite view {0} for user (ID: {1})".format(view_item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/views/{view_item.id}" + logger.info(f"Removing favorite view {view_item.id} for user (ID: {user_item.id})") self.delete_request(url) @api(version="2.3") def delete_favorite_datasource(self, user_item: UserItem, datasource_item: DatasourceItem) -> None: - url = "{0}/{1}/datasources/{2}".format(self.baseurl, user_item.id, datasource_item.id) - logger.info("Removing favorite {0} for user (ID: {1})".format(datasource_item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/datasources/{datasource_item.id}" + logger.info(f"Removing favorite {datasource_item.id} for user (ID: {user_item.id})") self.delete_request(url) @api(version="3.1") def delete_favorite_project(self, user_item: UserItem, project_item: ProjectItem) -> None: - url = "{0}/{1}/projects/{2}".format(self.baseurl, user_item.id, project_item.id) - logger.info("Removing favorite project {0} for user (ID: {1})".format(project_item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/projects/{project_item.id}" + logger.info(f"Removing favorite project {project_item.id} for user (ID: {user_item.id})") self.delete_request(url) @api(version="3.3") def delete_favorite_flow(self, user_item: UserItem, flow_item: FlowItem) -> None: - url = "{0}/{1}/flows/{2}".format(self.baseurl, user_item.id, flow_item.id) - logger.info("Removing favorite flow {0} for user (ID: {1})".format(flow_item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/flows/{flow_item.id}" + logger.info(f"Removing favorite flow {flow_item.id} for user (ID: {user_item.id})") self.delete_request(url) @api(version="3.15") def delete_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> None: - url = "{0}/{1}/metrics/{2}".format(self.baseurl, user_item.id, metric_item.id) - logger.info("Removing favorite metric {0} for user (ID: {1})".format(metric_item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/metrics/{metric_item.id}" + logger.info(f"Removing favorite metric {metric_item.id} for user (ID: {user_item.id})") self.delete_request(url) diff --git a/tableauserverclient/server/endpoint/fileuploads_endpoint.py b/tableauserverclient/server/endpoint/fileuploads_endpoint.py index 0d30797c1..1ae10e72d 100644 --- a/tableauserverclient/server/endpoint/fileuploads_endpoint.py +++ b/tableauserverclient/server/endpoint/fileuploads_endpoint.py @@ -9,11 +9,11 @@ class Fileuploads(Endpoint): def __init__(self, parent_srv): - super(Fileuploads, self).__init__(parent_srv) + super().__init__(parent_srv) @property def baseurl(self): - return "{0}/sites/{1}/fileUploads".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/fileUploads" @api(version="2.0") def initiate(self): @@ -21,14 +21,14 @@ def initiate(self): server_response = self.post_request(url, "") fileupload_item = FileuploadItem.from_response(server_response.content, self.parent_srv.namespace) upload_id = fileupload_item.upload_session_id - logger.info("Initiated file upload session (ID: {0})".format(upload_id)) + logger.info(f"Initiated file upload session (ID: {upload_id})") return upload_id @api(version="2.0") def append(self, upload_id, data, content_type): - url = "{0}/{1}".format(self.baseurl, upload_id) + url = f"{self.baseurl}/{upload_id}" server_response = self.put_request(url, data, content_type) - logger.info("Uploading a chunk to session (ID: {0})".format(upload_id)) + logger.info(f"Uploading a chunk to session (ID: {upload_id})") return FileuploadItem.from_response(server_response.content, self.parent_srv.namespace) def _read_chunks(self, file): @@ -52,12 +52,10 @@ def _read_chunks(self, file): def upload(self, file): upload_id = self.initiate() for chunk in self._read_chunks(file): - logger.debug("{} processing chunk...".format(datetime.timestamp())) + logger.debug(f"{datetime.timestamp()} processing chunk...") request, content_type = RequestFactory.Fileupload.chunk_req(chunk) - logger.debug("{} created chunk request".format(datetime.timestamp())) + logger.debug(f"{datetime.timestamp()} created chunk request") fileupload_item = self.append(upload_id, request, content_type) - logger.info( - "\t{0} Published {1}MB".format(datetime.timestamp(), (fileupload_item.file_size / BYTES_PER_MB)) - ) - logger.info("File upload finished (ID: {0})".format(upload_id)) + logger.info(f"\t{datetime.timestamp()} Published {(fileupload_item.file_size / BYTES_PER_MB)}MB") + logger.info(f"File upload finished (ID: {upload_id})") return upload_id diff --git a/tableauserverclient/server/endpoint/flow_runs_endpoint.py b/tableauserverclient/server/endpoint/flow_runs_endpoint.py index c339a0645..2c3bb84bc 100644 --- a/tableauserverclient/server/endpoint/flow_runs_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_runs_endpoint.py @@ -1,9 +1,9 @@ import logging -from typing import List, Optional, Tuple, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING, Union from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api from tableauserverclient.server.endpoint.exceptions import FlowRunFailedException, FlowRunCancelledException -from tableauserverclient.models import FlowRunItem, PaginationItem +from tableauserverclient.models import FlowRunItem from tableauserverclient.exponential_backoff import ExponentialBackoffTimer from tableauserverclient.helpers.logging import logger @@ -16,22 +16,24 @@ class FlowRuns(QuerysetEndpoint[FlowRunItem]): def __init__(self, parent_srv: "Server") -> None: - super(FlowRuns, self).__init__(parent_srv) + super().__init__(parent_srv) return None @property def baseurl(self) -> str: - return "{0}/sites/{1}/flows/runs".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/flows/runs" # Get all flows @api(version="3.10") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[FlowRunItem], PaginationItem]: + # QuerysetEndpoint expects a PaginationItem to be returned, but FlowRuns + # does not return a PaginationItem. Suppressing the mypy error because the + # changes to the QuerySet class should permit this to function regardless. + def get(self, req_options: Optional["RequestOptions"] = None) -> list[FlowRunItem]: # type: ignore[override] logger.info("Querying all flow runs on site") url = self.baseurl server_response = self.get_request(url, req_options) - pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) all_flow_run_items = FlowRunItem.from_response(server_response.content, self.parent_srv.namespace) - return all_flow_run_items, pagination_item + return all_flow_run_items # Get 1 flow by id @api(version="3.10") @@ -39,21 +41,21 @@ def get_by_id(self, flow_run_id: str) -> FlowRunItem: if not flow_run_id: error = "Flow ID undefined." raise ValueError(error) - logger.info("Querying single flow (ID: {0})".format(flow_run_id)) - url = "{0}/{1}".format(self.baseurl, flow_run_id) + logger.info(f"Querying single flow (ID: {flow_run_id})") + url = f"{self.baseurl}/{flow_run_id}" server_response = self.get_request(url) return FlowRunItem.from_response(server_response.content, self.parent_srv.namespace)[0] # Cancel 1 flow run by id @api(version="3.10") - def cancel(self, flow_run_id: str) -> None: + def cancel(self, flow_run_id: Union[str, FlowRunItem]) -> None: if not flow_run_id: error = "Flow ID undefined." raise ValueError(error) id_ = getattr(flow_run_id, "id", flow_run_id) - url = "{0}/{1}".format(self.baseurl, id_) + url = f"{self.baseurl}/{id_}" self.put_request(url) - logger.info("Deleted single flow (ID: {0})".format(id_)) + logger.info(f"Deleted single flow (ID: {id_})") @api(version="3.10") def wait_for_job(self, flow_run_id: str, *, timeout: Optional[int] = None) -> FlowRunItem: @@ -69,7 +71,7 @@ def wait_for_job(self, flow_run_id: str, *, timeout: Optional[int] = None) -> Fl flow_run = self.get_by_id(flow_run_id) logger.debug(f"\tFlowRun {flow_run_id} progress={flow_run.progress}") - logger.info("FlowRun {} Completed: Status: {}".format(flow_run_id, flow_run.status)) + logger.info(f"FlowRun {flow_run_id} Completed: Status: {flow_run.status}") if flow_run.status == "Success": return flow_run diff --git a/tableauserverclient/server/endpoint/flow_task_endpoint.py b/tableauserverclient/server/endpoint/flow_task_endpoint.py index eea3f9710..9e21661e6 100644 --- a/tableauserverclient/server/endpoint/flow_task_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_task_endpoint.py @@ -1,5 +1,5 @@ import logging -from typing import List, Optional, Tuple, TYPE_CHECKING +from typing import TYPE_CHECKING from tableauserverclient.server.endpoint.endpoint import Endpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError @@ -15,7 +15,7 @@ class FlowTasks(Endpoint): @property def baseurl(self) -> str: - return "{0}/sites/{1}/tasks/flows".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks/flows" @api(version="3.22") def create(self, flow_item: TaskItem) -> TaskItem: diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 53d072f50..7eb5dc3ba 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -5,7 +5,8 @@ import os from contextlib import closing from pathlib import Path -from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union +from typing import Optional, TYPE_CHECKING, Union +from collections.abc import Iterable from tableauserverclient.helpers.headers import fix_filename @@ -53,18 +54,18 @@ class Flows(QuerysetEndpoint[FlowItem], TaggingMixin[FlowItem]): def __init__(self, parent_srv): - super(Flows, self).__init__(parent_srv) + super().__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "flow") @property def baseurl(self) -> str: - return "{0}/sites/{1}/flows".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/flows" # Get all flows @api(version="3.3") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[FlowItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[FlowItem], PaginationItem]: logger.info("Querying all flows on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -78,8 +79,8 @@ def get_by_id(self, flow_id: str) -> FlowItem: if not flow_id: error = "Flow ID undefined." raise ValueError(error) - logger.info("Querying single flow (ID: {0})".format(flow_id)) - url = "{0}/{1}".format(self.baseurl, flow_id) + logger.info(f"Querying single flow (ID: {flow_id})") + url = f"{self.baseurl}/{flow_id}" server_response = self.get_request(url) return FlowItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -94,10 +95,10 @@ def connections_fetcher(): return self._get_flow_connections(flow_item) flow_item._set_connections(connections_fetcher) - logger.info("Populated connections for flow (ID: {0})".format(flow_item.id)) + logger.info(f"Populated connections for flow (ID: {flow_item.id})") - def _get_flow_connections(self, flow_item, req_options: Optional["RequestOptions"] = None) -> List[ConnectionItem]: - url = "{0}/{1}/connections".format(self.baseurl, flow_item.id) + def _get_flow_connections(self, flow_item, req_options: Optional["RequestOptions"] = None) -> list[ConnectionItem]: + url = f"{self.baseurl}/{flow_item.id}/connections" server_response = self.get_request(url, req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) return connections @@ -108,9 +109,9 @@ def delete(self, flow_id: str) -> None: if not flow_id: error = "Flow ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, flow_id) + url = f"{self.baseurl}/{flow_id}" self.delete_request(url) - logger.info("Deleted single flow (ID: {0})".format(flow_id)) + logger.info(f"Deleted single flow (ID: {flow_id})") # Download 1 flow by id @api(version="3.3") @@ -118,7 +119,7 @@ def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> Path if not flow_id: error = "Flow ID undefined." raise ValueError(error) - url = "{0}/{1}/content".format(self.baseurl, flow_id) + url = f"{self.baseurl}/{flow_id}/content" with closing(self.get_request(url, parameters={"stream": True})) as server_response: m = Message() @@ -137,7 +138,7 @@ def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> Path f.write(chunk) return_path = os.path.abspath(download_path) - logger.info("Downloaded flow to {0} (ID: {1})".format(return_path, flow_id)) + logger.info(f"Downloaded flow to {return_path} (ID: {flow_id})") return return_path # Update flow @@ -150,28 +151,28 @@ def update(self, flow_item: FlowItem) -> FlowItem: self._resource_tagger.update_tags(self.baseurl, flow_item) # Update the flow itself - url = "{0}/{1}".format(self.baseurl, flow_item.id) + url = f"{self.baseurl}/{flow_item.id}" update_req = RequestFactory.Flow.update_req(flow_item) server_response = self.put_request(url, update_req) - logger.info("Updated flow item (ID: {0})".format(flow_item.id)) + logger.info(f"Updated flow item (ID: {flow_item.id})") updated_flow = copy.copy(flow_item) return updated_flow._parse_common_elements(server_response.content, self.parent_srv.namespace) # Update flow connections @api(version="3.3") def update_connection(self, flow_item: FlowItem, connection_item: ConnectionItem) -> ConnectionItem: - url = "{0}/{1}/connections/{2}".format(self.baseurl, flow_item.id, connection_item.id) + url = f"{self.baseurl}/{flow_item.id}/connections/{connection_item.id}" update_req = RequestFactory.Connection.update_req(connection_item) server_response = self.put_request(url, update_req) connection = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Updated flow item (ID: {0} & connection item {1}".format(flow_item.id, connection_item.id)) + logger.info(f"Updated flow item (ID: {flow_item.id} & connection item {connection_item.id}") return connection @api(version="3.3") def refresh(self, flow_item: FlowItem) -> JobItem: - url = "{0}/{1}/run".format(self.baseurl, flow_item.id) + url = f"{self.baseurl}/{flow_item.id}/run" empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -180,7 +181,7 @@ def refresh(self, flow_item: FlowItem) -> JobItem: # Publish flow @api(version="3.3") def publish( - self, flow_item: FlowItem, file: PathOrFileR, mode: str, connections: Optional[List[ConnectionItem]] = None + self, flow_item: FlowItem, file: PathOrFileR, mode: str, connections: Optional[list[ConnectionItem]] = None ) -> FlowItem: if not mode or not hasattr(self.parent_srv.PublishMode, mode): error = "Invalid mode defined." @@ -189,7 +190,7 @@ def publish( if isinstance(file, (str, os.PathLike)): if not os.path.isfile(file): error = "File path does not lead to an existing file." - raise IOError(error) + raise OSError(error) filename = os.path.basename(file) file_extension = os.path.splitext(filename)[1][1:] @@ -213,30 +214,30 @@ def publish( elif file_type == "xml": file_extension = "tfl" else: - error = "Unsupported file type {}!".format(file_type) + error = f"Unsupported file type {file_type}!" raise ValueError(error) # Generate filename for file object. # This is needed when publishing the flow in a single request - filename = "{}.{}".format(flow_item.name, file_extension) + filename = f"{flow_item.name}.{file_extension}" file_size = get_file_object_size(file) else: raise TypeError("file should be a filepath or file object.") # Construct the url with the defined mode - url = "{0}?flowType={1}".format(self.baseurl, file_extension) + url = f"{self.baseurl}?flowType={file_extension}" if mode == self.parent_srv.PublishMode.Overwrite or mode == self.parent_srv.PublishMode.Append: - url += "&{0}=true".format(mode.lower()) + url += f"&{mode.lower()}=true" # Determine if chunking is required (64MB is the limit for single upload method) if file_size >= FILESIZE_LIMIT: - logger.info("Publishing {0} to server with chunking method (flow over 64MB)".format(filename)) + logger.info(f"Publishing {filename} to server with chunking method (flow over 64MB)") upload_session_id = self.parent_srv.fileuploads.upload(file) - url = "{0}&uploadSessionId={1}".format(url, upload_session_id) + url = f"{url}&uploadSessionId={upload_session_id}" xml_request, content_type = RequestFactory.Flow.publish_req_chunked(flow_item, connections) else: - logger.info("Publishing {0} to server".format(filename)) + logger.info(f"Publishing {filename} to server") if isinstance(file, (str, Path)): with open(file, "rb") as f: @@ -259,7 +260,7 @@ def publish( raise err else: new_flow = FlowItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Published {0} (ID: {1})".format(filename, new_flow.id)) + logger.info(f"Published {filename} (ID: {new_flow.id})") return new_flow @api(version="3.3") @@ -294,7 +295,7 @@ def delete_dqw(self, item: FlowItem) -> None: @api(version="3.3") def schedule_flow_run( self, schedule_id: str, item: FlowItem - ) -> List["AddResponse"]: # actually should return a task + ) -> list["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, flow=item) def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[FlowItem]: diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index 8acf31692..c512b011b 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -8,7 +8,8 @@ from tableauserverclient.helpers.logging import logger -from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union +from typing import Optional, TYPE_CHECKING, Union +from collections.abc import Iterable from tableauserverclient.server.query import QuerySet @@ -19,10 +20,10 @@ class Groups(QuerysetEndpoint[GroupItem]): @property def baseurl(self) -> str: - return "{0}/sites/{1}/groups".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/groups" @api(version="2.0") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[GroupItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[GroupItem], PaginationItem]: """Gets all groups""" logger.info("Querying all groups on site") url = self.baseurl @@ -50,12 +51,12 @@ def user_pager(): def _get_users_for_group( self, group_item: GroupItem, req_options: Optional["RequestOptions"] = None - ) -> Tuple[List[UserItem], PaginationItem]: - url = "{0}/{1}/users".format(self.baseurl, group_item.id) + ) -> tuple[list[UserItem], PaginationItem]: + url = f"{self.baseurl}/{group_item.id}/users" server_response = self.get_request(url, req_options) user_item = UserItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) - logger.info("Populated users for group (ID: {0})".format(group_item.id)) + logger.info(f"Populated users for group (ID: {group_item.id})") return user_item, pagination_item @api(version="2.0") @@ -64,13 +65,13 @@ def delete(self, group_id: str) -> None: if not group_id: error = "Group ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, group_id) + url = f"{self.baseurl}/{group_id}" self.delete_request(url) - logger.info("Deleted single group (ID: {0})".format(group_id)) + logger.info(f"Deleted single group (ID: {group_id})") @api(version="2.0") def update(self, group_item: GroupItem, as_job: bool = False) -> Union[GroupItem, JobItem]: - url = "{0}/{1}".format(self.baseurl, group_item.id) + url = f"{self.baseurl}/{group_item.id}" if not group_item.id: error = "Group item missing ID." @@ -83,7 +84,7 @@ def update(self, group_item: GroupItem, as_job: bool = False) -> Union[GroupItem 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)) + logger.info(f"Updated group item (ID: {group_item.id})") if as_job: return JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] else: @@ -118,9 +119,9 @@ def remove_user(self, group_item: GroupItem, user_id: str) -> None: if not user_id: error = "User ID undefined." raise ValueError(error) - url = "{0}/{1}/users/{2}".format(self.baseurl, group_item.id, user_id) + url = f"{self.baseurl}/{group_item.id}/users/{user_id}" self.delete_request(url) - logger.info("Removed user (id: {0}) from group (ID: {1})".format(user_id, group_item.id)) + logger.info(f"Removed user (id: {user_id}) from group (ID: {group_item.id})") @api(version="3.21") def remove_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> None: @@ -132,7 +133,7 @@ def remove_users(self, group_item: GroupItem, users: Iterable[Union[str, UserIte url = f"{self.baseurl}/{group_id}/users/remove" add_req = RequestFactory.Group.remove_users_req(users) _ = self.put_request(url, add_req) - logger.info("Removed users to group (ID: {0})".format(group_item.id)) + logger.info(f"Removed users to group (ID: {group_item.id})") return None @api(version="2.0") @@ -144,15 +145,15 @@ def add_user(self, group_item: GroupItem, user_id: str) -> UserItem: if not user_id: error = "User ID undefined." raise ValueError(error) - url = "{0}/{1}/users".format(self.baseurl, group_item.id) + url = f"{self.baseurl}/{group_item.id}/users" add_req = RequestFactory.Group.add_user_req(user_id) server_response = self.post_request(url, add_req) user = UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() - logger.info("Added user (id: {0}) to group (ID: {1})".format(user_id, group_item.id)) + logger.info(f"Added user (id: {user_id}) to group (ID: {group_item.id})") return user @api(version="3.21") - def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> List[UserItem]: + def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> list[UserItem]: """Adds multiple users to 1 group""" group_id = group_item.id if hasattr(group_item, "id") else group_item if not isinstance(group_id, str): @@ -162,7 +163,7 @@ def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]] add_req = RequestFactory.Group.add_users_req(users) server_response = self.post_request(url, add_req) users = UserItem.from_response(server_response.content, self.parent_srv.namespace) - logger.info("Added users to group (ID: {0})".format(group_item.id)) + logger.info(f"Added users to group (ID: {group_item.id})") return users def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[GroupItem]: diff --git a/tableauserverclient/server/endpoint/groupsets_endpoint.py b/tableauserverclient/server/endpoint/groupsets_endpoint.py index 06e7cc627..c7f5ed0e5 100644 --- a/tableauserverclient/server/endpoint/groupsets_endpoint.py +++ b/tableauserverclient/server/endpoint/groupsets_endpoint.py @@ -1,4 +1,4 @@ -from typing import List, Literal, Optional, Tuple, TYPE_CHECKING, Union +from typing import Literal, Optional, TYPE_CHECKING, Union from tableauserverclient.helpers.logging import logger from tableauserverclient.models.group_item import GroupItem @@ -27,7 +27,7 @@ def get( self, request_options: Optional[RequestOptions] = None, result_level: Optional[Literal["members", "local"]] = None, - ) -> Tuple[List[GroupSetItem], PaginationItem]: + ) -> tuple[list[GroupSetItem], PaginationItem]: logger.info("Querying all group sets on site") url = self.baseurl if result_level: diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index ae8cf2633..723d3dd38 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -11,24 +11,24 @@ from tableauserverclient.helpers.logging import logger -from typing import List, Optional, Tuple, Union +from typing import Optional, Union class Jobs(QuerysetEndpoint[BackgroundJobItem]): @property def baseurl(self): - return "{0}/sites/{1}/jobs".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/jobs" @overload # type: ignore[override] def get(self: Self, job_id: str, req_options: Optional[RequestOptionsBase] = None) -> JobItem: # type: ignore[override] ... @overload # type: ignore[override] - def get(self: Self, job_id: RequestOptionsBase, req_options: None) -> Tuple[List[BackgroundJobItem], PaginationItem]: # type: ignore[override] + def get(self: Self, job_id: RequestOptionsBase, req_options: None) -> tuple[list[BackgroundJobItem], PaginationItem]: # type: ignore[override] ... @overload # type: ignore[override] - def get(self: Self, job_id: None, req_options: Optional[RequestOptionsBase]) -> Tuple[List[BackgroundJobItem], PaginationItem]: # type: ignore[override] + def get(self: Self, job_id: None, req_options: Optional[RequestOptionsBase]) -> tuple[list[BackgroundJobItem], PaginationItem]: # type: ignore[override] ... @api(version="2.6") @@ -53,13 +53,13 @@ def cancel(self, job_id: Union[str, JobItem]): if isinstance(job_id, JobItem): job_id = job_id.id assert isinstance(job_id, str) - url = "{0}/{1}".format(self.baseurl, job_id) + url = f"{self.baseurl}/{job_id}" return self.put_request(url) @api(version="2.6") def get_by_id(self, job_id: str) -> JobItem: logger.info("Query for information about job " + job_id) - url = "{0}/{1}".format(self.baseurl, job_id) + url = f"{self.baseurl}/{job_id}" server_response = self.get_request(url) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job @@ -77,7 +77,7 @@ def wait_for_job(self, job_id: Union[str, JobItem], *, timeout: Optional[float] job = self.get_by_id(job_id) logger.debug(f"\tJob {job_id} progress={job.progress}") - logger.info("Job {} Completed: Finish Code: {} - Notes:{}".format(job_id, job.finish_code, job.notes)) + logger.info(f"Job {job_id} Completed: Finish Code: {job.finish_code} - Notes:{job.notes}") if job.finish_code == JobItem.FinishCode.Success: return job diff --git a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py index 374130509..ede4d38e3 100644 --- a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Tuple, Union +from typing import Optional, Union from tableauserverclient.helpers.logging import logger from tableauserverclient.models.linked_tasks_item import LinkedTaskItem, LinkedTaskJobItem @@ -18,7 +18,7 @@ def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks/linked" @api(version="3.15") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[LinkedTaskItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[LinkedTaskItem], PaginationItem]: logger.info("Querying all linked tasks on site") url = self.baseurl server_response = self.get_request(url, req_options) diff --git a/tableauserverclient/server/endpoint/metadata_endpoint.py b/tableauserverclient/server/endpoint/metadata_endpoint.py index 38c3eebb6..e5dbcbcf8 100644 --- a/tableauserverclient/server/endpoint/metadata_endpoint.py +++ b/tableauserverclient/server/endpoint/metadata_endpoint.py @@ -50,11 +50,11 @@ def get_page_info(result): class Metadata(Endpoint): @property def baseurl(self): - return "{0}/api/metadata/graphql".format(self.parent_srv.server_address) + return f"{self.parent_srv.server_address}/api/metadata/graphql" @property def control_baseurl(self): - return "{0}/api/metadata/v1/control".format(self.parent_srv.server_address) + return f"{self.parent_srv.server_address}/api/metadata/v1/control" @api("3.5") def query(self, query, variables=None, abort_on_error=False, parameters=None): diff --git a/tableauserverclient/server/endpoint/metrics_endpoint.py b/tableauserverclient/server/endpoint/metrics_endpoint.py index ab1ec5852..3fea1f5b6 100644 --- a/tableauserverclient/server/endpoint/metrics_endpoint.py +++ b/tableauserverclient/server/endpoint/metrics_endpoint.py @@ -8,7 +8,7 @@ import logging -from typing import List, Optional, TYPE_CHECKING, Tuple +from typing import Optional, TYPE_CHECKING if TYPE_CHECKING: from ..request_options import RequestOptions @@ -20,18 +20,18 @@ class Metrics(QuerysetEndpoint[MetricItem]): def __init__(self, parent_srv: "Server") -> None: - super(Metrics, self).__init__(parent_srv) + super().__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "metric") @property def baseurl(self) -> str: - return "{0}/sites/{1}/metrics".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/metrics" # Get all metrics @api(version="3.9") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[MetricItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[MetricItem], PaginationItem]: logger.info("Querying all metrics on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -45,8 +45,8 @@ def get_by_id(self, metric_id: str) -> MetricItem: if not metric_id: error = "Metric ID undefined." raise ValueError(error) - logger.info("Querying single metric (ID: {0})".format(metric_id)) - url = "{0}/{1}".format(self.baseurl, metric_id) + logger.info(f"Querying single metric (ID: {metric_id})") + url = f"{self.baseurl}/{metric_id}" server_response = self.get_request(url) return MetricItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -56,9 +56,9 @@ def delete(self, metric_id: str) -> None: if not metric_id: error = "Metric ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, metric_id) + url = f"{self.baseurl}/{metric_id}" self.delete_request(url) - logger.info("Deleted single metric (ID: {0})".format(metric_id)) + logger.info(f"Deleted single metric (ID: {metric_id})") # Update metric @api(version="3.9") @@ -70,8 +70,8 @@ def update(self, metric_item: MetricItem) -> MetricItem: self._resource_tagger.update_tags(self.baseurl, metric_item) # Update the metric itself - url = "{0}/{1}".format(self.baseurl, metric_item.id) + url = f"{self.baseurl}/{metric_item.id}" update_req = RequestFactory.Metric.update_req(metric_item) server_response = self.put_request(url, update_req) - logger.info("Updated metric item (ID: {0})".format(metric_item.id)) + logger.info(f"Updated metric item (ID: {metric_item.id})") return MetricItem.from_response(server_response.content, self.parent_srv.namespace)[0] diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py index 4433625f2..10d420ff7 100644 --- a/tableauserverclient/server/endpoint/permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/permissions_endpoint.py @@ -6,7 +6,7 @@ from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError -from typing import Callable, TYPE_CHECKING, List, Optional, Union +from typing import Callable, TYPE_CHECKING, Optional, Union from tableauserverclient.helpers.logging import logger @@ -25,7 +25,7 @@ class _PermissionsEndpoint(Endpoint): """ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> None: - super(_PermissionsEndpoint, self).__init__(parent_srv) + super().__init__(parent_srv) # owner_baseurl is the baseurl of the parent. The MUST be a lambda # since we don't know the full site URL until we sign in. If @@ -33,18 +33,18 @@ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> No self.owner_baseurl = owner_baseurl def __str__(self): - return "".format(self.owner_baseurl) + return f"" - def update(self, resource: TableauItem, permissions: List[PermissionsRule]) -> List[PermissionsRule]: - url = "{0}/{1}/permissions".format(self.owner_baseurl(), resource.id) + def update(self, resource: TableauItem, permissions: list[PermissionsRule]) -> list[PermissionsRule]: + url = f"{self.owner_baseurl()}/{resource.id}/permissions" update_req = RequestFactory.Permission.add_req(permissions) response = self.put_request(url, update_req) permissions = PermissionsRule.from_response(response.content, self.parent_srv.namespace) - logger.info("Updated permissions for resource {0}: {1}".format(resource.id, permissions)) + logger.info(f"Updated permissions for resource {resource.id}: {permissions}") return permissions - def delete(self, resource: TableauItem, rules: Union[PermissionsRule, List[PermissionsRule]]): + def delete(self, resource: TableauItem, rules: Union[PermissionsRule, list[PermissionsRule]]): # Delete is the only endpoint that doesn't take a list of rules # so let's fake it to keep it consistent # TODO that means we need error handling around the call @@ -54,7 +54,7 @@ def delete(self, resource: TableauItem, rules: Union[PermissionsRule, List[Permi for rule in rules: for capability, mode in rule.capabilities.items(): "/permissions/groups/group-id/capability-name/capability-mode" - url = "{0}/{1}/permissions/{2}/{3}/{4}/{5}".format( + url = "{}/{}/permissions/{}/{}/{}/{}".format( self.owner_baseurl(), resource.id, rule.grantee.tag_name + "s", @@ -63,13 +63,11 @@ def delete(self, resource: TableauItem, rules: Union[PermissionsRule, List[Permi mode, ) - logger.debug("Removing {0} permission for capability {1}".format(mode, capability)) + logger.debug(f"Removing {mode} permission for capability {capability}") self.delete_request(url) - logger.info( - "Deleted permission for {0} {1} item {2}".format(rule.grantee.tag_name, rule.grantee.id, resource.id) - ) + logger.info(f"Deleted permission for {rule.grantee.tag_name} {rule.grantee.id} item {resource.id}") def populate(self, item: TableauItem): if not item.id: @@ -80,12 +78,12 @@ def permission_fetcher(): return self._get_permissions(item) item._set_permissions(permission_fetcher) - logger.info("Populated permissions for item (ID: {0})".format(item.id)) + logger.info(f"Populated permissions for item (ID: {item.id})") def _get_permissions(self, item: TableauItem, req_options: Optional["RequestOptions"] = None): - url = "{0}/{1}/permissions".format(self.owner_baseurl(), item.id) + url = f"{self.owner_baseurl()}/{item.id}/permissions" server_response = self.get_request(url, req_options) permissions = PermissionsRule.from_response(server_response.content, self.parent_srv.namespace) - logger.info("Permissions for resource {0}: {1}".format(item.id, permissions)) + logger.info(f"Permissions for resource {item.id}: {permissions}") return permissions diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 565817e37..74bb865c7 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -5,9 +5,10 @@ from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint from tableauserverclient.server import RequestFactory, RequestOptions +from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.models import ProjectItem, PaginationItem, Resource -from typing import List, Optional, Tuple, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from tableauserverclient.server.query import QuerySet @@ -20,17 +21,17 @@ class Projects(QuerysetEndpoint[ProjectItem]): def __init__(self, parent_srv: "Server") -> None: - super(Projects, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl) @property def baseurl(self) -> str: - return "{0}/sites/{1}/projects".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/projects" @api(version="2.0") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[ProjectItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[ProjectItem], PaginationItem]: logger.info("Querying all projects on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -43,9 +44,9 @@ def delete(self, project_id: str) -> None: if not project_id: error = "Project ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, project_id) + url = f"{self.baseurl}/{project_id}" self.delete_request(url) - logger.info("Deleted single project (ID: {0})".format(project_id)) + logger.info(f"Deleted single project (ID: {project_id})") @api(version="2.0") def update(self, project_item: ProjectItem, samples: bool = False) -> ProjectItem: @@ -54,10 +55,10 @@ def update(self, project_item: ProjectItem, samples: bool = False) -> ProjectIte raise MissingRequiredFieldError(error) params = {"params": {RequestOptions.Field.PublishSamples: samples}} - url = "{0}/{1}".format(self.baseurl, project_item.id) + url = f"{self.baseurl}/{project_item.id}" update_req = RequestFactory.Project.update_req(project_item) server_response = self.put_request(url, update_req, XML_CONTENT_TYPE, params) - logger.info("Updated project item (ID: {0})".format(project_item.id)) + logger.info(f"Updated project item (ID: {project_item.id})") updated_project = ProjectItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_project @@ -66,11 +67,11 @@ def create(self, project_item: ProjectItem, samples: bool = False) -> ProjectIte params = {"params": {RequestOptions.Field.PublishSamples: samples}} url = self.baseurl if project_item._samples: - url = "{0}?publishSamples={1}".format(self.baseurl, project_item._samples) + url = f"{self.baseurl}?publishSamples={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] - logger.info("Created new project (ID: {0})".format(new_project.id)) + logger.info(f"Created new project (ID: {new_project.id})") return new_project @api(version="2.0") @@ -78,85 +79,135 @@ def populate_permissions(self, item: ProjectItem) -> None: self._permissions.populate(item) @api(version="2.0") - def update_permissions(self, item, rules): + def update_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: return self._permissions.update(item, rules) @api(version="2.0") - def delete_permission(self, item, rules): + def delete_permission(self, item: ProjectItem, rules: list[PermissionsRule]) -> None: self._permissions.delete(item, rules) @api(version="2.1") - def populate_workbook_default_permissions(self, item): + def populate_workbook_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.Workbook) @api(version="2.1") - def populate_datasource_default_permissions(self, item): + def populate_datasource_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.Datasource) @api(version="3.2") - def populate_metric_default_permissions(self, item): + def populate_metric_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.Metric) @api(version="3.4") - def populate_datarole_default_permissions(self, item): + def populate_datarole_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.Datarole) @api(version="3.4") - def populate_flow_default_permissions(self, item): + def populate_flow_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.Flow) @api(version="3.4") - def populate_lens_default_permissions(self, item): + def populate_lens_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.Lens) + @api(version="3.23") + def populate_virtualconnection_default_permissions(self, item: ProjectItem) -> None: + self._default_permissions.populate_default_permissions(item, Resource.VirtualConnection) + + @api(version="3.23") + def populate_database_default_permissions(self, item: ProjectItem) -> None: + self._default_permissions.populate_default_permissions(item, Resource.Database) + + @api(version="3.23") + def populate_table_default_permissions(self, item: ProjectItem) -> None: + self._default_permissions.populate_default_permissions(item, Resource.Table) + @api(version="2.1") - def update_workbook_default_permissions(self, item, rules): + def update_workbook_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.Workbook) @api(version="2.1") - def update_datasource_default_permissions(self, item, rules): + def update_datasource_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.Datasource) @api(version="3.2") - def update_metric_default_permissions(self, item, rules): + def update_metric_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.Metric) @api(version="3.4") - def update_datarole_default_permissions(self, item, rules): + def update_datarole_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.Datarole) @api(version="3.4") - def update_flow_default_permissions(self, item, rules): + def update_flow_default_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.Flow) @api(version="3.4") - def update_lens_default_permissions(self, item, rules): + def update_lens_default_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.Lens) + @api(version="3.23") + def update_virtualconnection_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: + return self._default_permissions.update_default_permissions(item, rules, Resource.VirtualConnection) + + @api(version="3.23") + def update_database_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: + return self._default_permissions.update_default_permissions(item, rules, Resource.Database) + + @api(version="3.23") + def update_table_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: + return self._default_permissions.update_default_permissions(item, rules, Resource.Table) + @api(version="2.1") - def delete_workbook_default_permissions(self, item, rule): + def delete_workbook_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.Workbook) @api(version="2.1") - def delete_datasource_default_permissions(self, item, rule): + def delete_datasource_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.Datasource) @api(version="3.2") - def delete_metric_default_permissions(self, item, rule): + def delete_metric_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.Metric) @api(version="3.4") - def delete_datarole_default_permissions(self, item, rule): + def delete_datarole_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.Datarole) @api(version="3.4") - def delete_flow_default_permissions(self, item, rule): + def delete_flow_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.Flow) @api(version="3.4") - def delete_lens_default_permissions(self, item, rule): + def delete_lens_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.Lens) + @api(version="3.23") + def delete_virtualconnection_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + self._default_permissions.delete_default_permission(item, rule, Resource.VirtualConnection) + + @api(version="3.23") + def delete_database_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + self._default_permissions.delete_default_permission(item, rule, Resource.Database) + + @api(version="3.23") + def delete_table_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + self._default_permissions.delete_default_permission(item, rule, Resource.Table) + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[ProjectItem]: """ Queries the Tableau Server for items using the specified filters. Page diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index 1894e3b8a..63c03b3e3 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -1,6 +1,7 @@ import abc import copy -from typing import Generic, Iterable, Optional, Protocol, Set, TypeVar, Union, TYPE_CHECKING, runtime_checkable +from typing import Generic, Optional, Protocol, TypeVar, Union, TYPE_CHECKING, runtime_checkable +from collections.abc import Iterable import urllib.parse from tableauserverclient.server.endpoint.endpoint import Endpoint, api @@ -24,7 +25,7 @@ class _ResourceTagger(Endpoint): # Add new tags to resource def _add_tags(self, baseurl, resource_id, tag_set): - url = "{0}/{1}/tags".format(baseurl, resource_id) + url = f"{baseurl}/{resource_id}/tags" add_req = RequestFactory.Tag.add_req(tag_set) try: @@ -39,7 +40,7 @@ def _add_tags(self, baseurl, resource_id, tag_set): # Delete a resource's tag by name def _delete_tag(self, baseurl, resource_id, tag_name): encoded_tag_name = urllib.parse.quote(tag_name) - url = "{0}/{1}/tags/{2}".format(baseurl, resource_id, encoded_tag_name) + url = f"{baseurl}/{resource_id}/tags/{encoded_tag_name}" try: self.delete_request(url) @@ -59,7 +60,7 @@ def update_tags(self, baseurl, resource_item): if add_set: resource_item.tags = self._add_tags(baseurl, resource_item.id, add_set) resource_item._initial_tags = copy.copy(resource_item.tags) - logger.info("Updated tags to {0}".format(resource_item.tags)) + logger.info(f"Updated tags to {resource_item.tags}") class Response(Protocol): @@ -68,8 +69,8 @@ class Response(Protocol): @runtime_checkable class Taggable(Protocol): - tags: Set[str] - _initial_tags: Set[str] + tags: set[str] + _initial_tags: set[str] @property def id(self) -> Optional[str]: @@ -95,14 +96,14 @@ def put_request(self, url, request) -> Response: def delete_request(self, url) -> None: pass - def add_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> Set[str]: + def add_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> set[str]: item_id = getattr(item, "id", item) if not isinstance(item_id, str): raise ValueError("ID not found.") if isinstance(tags, str): - tag_set = set([tags]) + tag_set = {tags} else: tag_set = set(tags) @@ -118,7 +119,7 @@ def delete_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> N raise ValueError("ID not found.") if isinstance(tags, str): - tag_set = set([tags]) + tag_set = {tags} else: tag_set = set(tags) @@ -158,9 +159,9 @@ def baseurl(self): return f"{self.parent_srv.baseurl}/tags" @api(version="3.9") - def batch_add(self, tags: Union[Iterable[str], str], content: content) -> Set[str]: + def batch_add(self, tags: Union[Iterable[str], str], content: content) -> set[str]: if isinstance(tags, str): - tag_set = set([tags]) + tag_set = {tags} else: tag_set = set(tags) @@ -170,9 +171,9 @@ def batch_add(self, tags: Union[Iterable[str], str], content: content) -> Set[st return TagItem.from_response(server_response.content, self.parent_srv.namespace) @api(version="3.9") - def batch_delete(self, tags: Union[Iterable[str], str], content: content) -> Set[str]: + def batch_delete(self, tags: Union[Iterable[str], str], content: content) -> set[str]: if isinstance(tags, str): - tag_set = set([tags]) + tag_set = {tags} else: tag_set = set(tags) diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index cfaee3324..eec4536f9 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -2,7 +2,7 @@ import logging import warnings from collections import namedtuple -from typing import TYPE_CHECKING, Callable, List, Optional, Tuple, Union +from typing import TYPE_CHECKING, Callable, Optional, Union from .endpoint import Endpoint, api, parameter_added_in from .exceptions import MissingRequiredFieldError @@ -22,14 +22,14 @@ class Schedules(Endpoint): @property def baseurl(self) -> str: - return "{0}/schedules".format(self.parent_srv.baseurl) + return f"{self.parent_srv.baseurl}/schedules" @property def siteurl(self) -> str: - return "{0}/sites/{1}/schedules".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/schedules" @api(version="2.3") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[ScheduleItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[ScheduleItem], PaginationItem]: logger.info("Querying all schedules") url = self.baseurl server_response = self.get_request(url, req_options) @@ -42,8 +42,8 @@ def get_by_id(self, schedule_id): if not schedule_id: error = "No Schedule ID provided" raise ValueError(error) - logger.info("Querying a single schedule by id ({})".format(schedule_id)) - url = "{0}/{1}".format(self.baseurl, schedule_id) + logger.info(f"Querying a single schedule by id ({schedule_id})") + url = f"{self.baseurl}/{schedule_id}" server_response = self.get_request(url) return ScheduleItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -52,9 +52,9 @@ def delete(self, schedule_id: str) -> None: if not schedule_id: error = "Schedule ID undefined" raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, schedule_id) + url = f"{self.baseurl}/{schedule_id}" self.delete_request(url) - logger.info("Deleted single schedule (ID: {0})".format(schedule_id)) + logger.info(f"Deleted single schedule (ID: {schedule_id})") @api(version="2.3") def update(self, schedule_item: ScheduleItem) -> ScheduleItem: @@ -62,10 +62,10 @@ def update(self, schedule_item: ScheduleItem) -> ScheduleItem: error = "Schedule item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}".format(self.baseurl, schedule_item.id) + url = f"{self.baseurl}/{schedule_item.id}" update_req = RequestFactory.Schedule.update_req(schedule_item) server_response = self.put_request(url, update_req) - logger.info("Updated schedule item (ID: {})".format(schedule_item.id)) + logger.info(f"Updated schedule item (ID: {schedule_item.id})") updated_schedule = copy.copy(schedule_item) return updated_schedule._parse_common_tags(server_response.content, self.parent_srv.namespace) @@ -79,7 +79,7 @@ def create(self, schedule_item: ScheduleItem) -> ScheduleItem: create_req = RequestFactory.Schedule.create_req(schedule_item) server_response = self.post_request(url, create_req) new_schedule = ScheduleItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Created new schedule (ID: {})".format(new_schedule.id)) + logger.info(f"Created new schedule (ID: {new_schedule.id})") return new_schedule @api(version="2.8") @@ -91,12 +91,12 @@ def add_to_schedule( datasource: Optional["DatasourceItem"] = None, flow: Optional["FlowItem"] = None, task_type: Optional[str] = None, - ) -> List[AddResponse]: + ) -> list[AddResponse]: # There doesn't seem to be a good reason to allow one item of each type? if workbook and datasource: warnings.warn("Passing in multiple items for add_to_schedule will be deprecated", PendingDeprecationWarning) - items: List[ - Tuple[str, Union[WorkbookItem, FlowItem, DatasourceItem], str, Callable[[Optional[str], str], bytes], str] + items: list[ + tuple[str, Union[WorkbookItem, FlowItem, DatasourceItem], str, Callable[[Optional[str], str], bytes], str] ] = [] if workbook is not None: @@ -115,8 +115,7 @@ def add_to_schedule( ) # type:ignore[arg-type] results = (self._add_to(*x) for x in items) - # list() is needed for python 3.x compatibility - return list(filter(lambda x: not x.result, results)) # type:ignore[arg-type] + return [x for x in results if not x.result] def _add_to( self, @@ -133,13 +132,13 @@ def _add_to( item_task_type, ) -> AddResponse: id_ = resource.id - url = "{0}/{1}/{2}s".format(self.siteurl, schedule_id, type_) + url = f"{self.siteurl}/{schedule_id}/{type_}s" add_req = req_factory(id_, task_type=item_task_type) # type: ignore[call-arg, arg-type] response = self.put_request(url, add_req) error, warnings, task_created = ScheduleItem.parse_add_to_schedule_response(response, self.parent_srv.namespace) if task_created: - logger.info("Added {} to {} to schedule {}".format(type_, id_, schedule_id)) + logger.info(f"Added {type_} to {id_} to schedule {schedule_id}") if error is not None or warnings is not None: return AddResponse( diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index 26aaf2910..dc934496a 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -1,4 +1,5 @@ import logging +from typing import Union from .endpoint import Endpoint, api from .exceptions import ServerResponseError @@ -21,15 +22,49 @@ def serverInfo(self): return self._info def __repr__(self): - return "".format(self.serverInfo) + return f"" @property - def baseurl(self): - return "{0}/serverInfo".format(self.parent_srv.baseurl) + def baseurl(self) -> str: + return f"{self.parent_srv.baseurl}/serverInfo" @api(version="2.4") - def get(self): - """Retrieve the server info for the server. This is an unauthenticated call""" + def get(self) -> Union[ServerInfoItem, None]: + """ + Retrieve the build and version information for the server. + + This method makes an unauthenticated call, so no sign in or + authentication token is required. + + Returns + ------- + :class:`~tableauserverclient.models.ServerInfoItem` + + Raises + ------ + :class:`~tableauserverclient.exceptions.ServerInfoEndpointNotFoundError` + Raised when the server info endpoint is not found. + + :class:`~tableauserverclient.exceptions.EndpointUnavailableError` + Raised when the server info endpoint is not available. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> # create a instance of server + >>> server = TSC.Server('https://MY-SERVER') + + >>> # set the version number > 2.3 + >>> # the server_info.get() method works in 2.4 and later + >>> server.version = '2.5' + + >>> s_info = server.server_info.get() + >>> print("\nServer info:") + >>> print("\tProduct version: {0}".format(s_info.product_version)) + >>> print("\tREST API version: {0}".format(s_info.rest_api_version)) + >>> print("\tBuild number: {0}".format(s_info.build_number)) + """ try: server_response = self.get_unauthenticated_request(self.baseurl) except ServerResponseError as e: diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index dfec49ae1..55d2a5ad0 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -8,20 +8,49 @@ from tableauserverclient.helpers.logging import logger -from typing import TYPE_CHECKING, List, Optional, Tuple +from typing import TYPE_CHECKING, Optional if TYPE_CHECKING: from ..request_options import RequestOptions class Sites(Endpoint): + """ + Using the site methods of the Tableau Server REST API you can: + + List sites on a server or get details of a specific site + Create, update, or delete a site + List views in a site + Encrypt, decrypt, or reencrypt extracts on a site + + """ + @property def baseurl(self) -> str: - return "{0}/sites".format(self.parent_srv.baseurl) + return f"{self.parent_srv.baseurl}/sites" # Gets all sites @api(version="2.0") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[SiteItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[SiteItem], PaginationItem]: + """ + Query all sites on the server. This method requires server admin + permissions. This endpoint is paginated, meaning that the server will + only return a subset of the data at a time. The response will contain + information about the total number of sites and the number of sites + returned in the current response. Use the PaginationItem object to + request more data. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_sites + + Parameters + ---------- + req_options : RequestOptions, optional + Filtering options for the request. + + Returns + ------- + tuple[list[SiteItem], PaginationItem] + """ logger.info("Querying all sites on site") logger.info("Requires Server Admin permissions") url = self.baseurl @@ -33,6 +62,33 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[Site # Gets 1 site by id @api(version="2.0") def get_by_id(self, site_id: str) -> SiteItem: + """ + Query a single site on the server. You can only retrieve the site that + you are currently authenticated for. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_site + + Parameters + ---------- + site_id : str + The site ID. + + Returns + ------- + SiteItem + + Raises + ------ + ValueError + If the site ID is not defined. + + ValueError + If the site ID does not match the site for which you are currently authenticated. + + Examples + -------- + >>> site = server.sites.get_by_id('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + """ if not site_id: error = "Site ID undefined." raise ValueError(error) @@ -40,20 +96,45 @@ def get_by_id(self, site_id: str) -> SiteItem: error = "You can only retrieve the site for which you are currently authenticated." raise ValueError(error) - logger.info("Querying single site (ID: {0})".format(site_id)) - url = "{0}/{1}".format(self.baseurl, site_id) + logger.info(f"Querying single site (ID: {site_id})") + url = f"{self.baseurl}/{site_id}" server_response = self.get_request(url) return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] # Gets 1 site by name @api(version="2.0") def get_by_name(self, site_name: str) -> SiteItem: + """ + Query a single site on the server. You can only retrieve the site that + you are currently authenticated for. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_site + + Parameters + ---------- + site_name : str + The site name. + + Returns + ------- + SiteItem + + Raises + ------ + ValueError + If the site name is not defined. + + Examples + -------- + >>> site = server.sites.get_by_name('Tableau') + + """ if not site_name: error = "Site Name undefined." raise ValueError(error) print("Note: You can only work with the site for which you are currently authenticated") - logger.info("Querying single site (Name: {0})".format(site_name)) - url = "{0}/{1}?key=name".format(self.baseurl, site_name) + logger.info(f"Querying single site (Name: {site_name})") + url = f"{self.baseurl}/{site_name}?key=name" print(self.baseurl, url) server_response = self.get_request(url) return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -61,6 +142,31 @@ def get_by_name(self, site_name: str) -> SiteItem: # Gets 1 site by content url @api(version="2.0") def get_by_content_url(self, content_url: str) -> SiteItem: + """ + Query a single site on the server. You can only retrieve the site that + you are currently authenticated for. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_site + + Parameters + ---------- + content_url : str + The content URL. + + Returns + ------- + SiteItem + + Raises + ------ + ValueError + If the site name is not defined. + + Examples + -------- + >>> site = server.sites.get_by_name('Tableau') + + """ if content_url is None: error = "Content URL undefined." raise ValueError(error) @@ -68,15 +174,51 @@ def get_by_content_url(self, content_url: str) -> SiteItem: error = "You can only work with the site you are currently authenticated for" raise ValueError(error) - logger.info("Querying single site (Content URL: {0})".format(content_url)) + logger.info(f"Querying single site (Content URL: {content_url})") logger.debug("Querying other sites requires Server Admin permissions") - url = "{0}/{1}?key=contentUrl".format(self.baseurl, content_url) + url = f"{self.baseurl}/{content_url}?key=contentUrl" server_response = self.get_request(url) return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] # Update site @api(version="2.0") def update(self, site_item: SiteItem) -> SiteItem: + """ + Modifies the settings for site. + + The site item object must include the site ID and overrides all other settings. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#update_site + + Parameters + ---------- + site_item : SiteItem + The site item that you want to update. The settings specified in the + site item override the current site settings. + + Returns + ------- + SiteItem + The site item object that was updated. + + Raises + ------ + MissingRequiredFieldError + If the site item is missing an ID. + + ValueError + If the site ID does not match the site for which you are currently authenticated. + + ValueError + If the site admin mode is set to ContentOnly and a user quota is also set. + + Examples + -------- + >>> ... + >>> site_item.name = 'New Name' + >>> updated_site = server.sites.update(site_item) + + """ if not site_item.id: error = "Site item missing ID." raise MissingRequiredFieldError(error) @@ -90,30 +232,94 @@ def update(self, site_item: SiteItem) -> SiteItem: error = "You cannot set admin_mode to ContentOnly and also set a user quota" raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, site_item.id) + url = f"{self.baseurl}/{site_item.id}" update_req = RequestFactory.Site.update_req(site_item, self.parent_srv) server_response = self.put_request(url, update_req) - logger.info("Updated site item (ID: {0})".format(site_item.id)) + logger.info(f"Updated site item (ID: {site_item.id})") update_site = copy.copy(site_item) return update_site._parse_common_tags(server_response.content, self.parent_srv.namespace) # Delete 1 site object @api(version="2.0") def delete(self, site_id: str) -> None: + """ + Deletes the specified site from the server. You can only delete the site + if you are a Server Admin. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#delete_site + + Parameters + ---------- + site_id : str + The site ID. + + Raises + ------ + ValueError + If the site ID is not defined. + + ValueError + If the site ID does not match the site for which you are currently authenticated. + + Examples + -------- + >>> server.sites.delete('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + """ if not site_id: error = "Site ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, site_id) + url = f"{self.baseurl}/{site_id}" if not site_id == self.parent_srv.site_id: error = "You can only delete the site you are currently authenticated for" raise ValueError(error) self.delete_request(url) self.parent_srv._clear_auth() - logger.info("Deleted single site (ID: {0}) and signed out".format(site_id)) + logger.info(f"Deleted single site (ID: {site_id}) and signed out") # Create new site @api(version="2.0") def create(self, site_item: SiteItem) -> SiteItem: + """ + Creates a new site on the server for the specified site item object. + + Tableau Server only. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#create_site + + Parameters + ---------- + site_item : SiteItem + The settings for the site that you want to create. You need to + create an instance of SiteItem and pass it to the create method. + + Returns + ------- + SiteItem + The site item object that was created. + + Raises + ------ + ValueError + If the site admin mode is set to ContentOnly and a user quota is also set. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> # create an instance of server + >>> server = TSC.Server('https://MY-SERVER') + + >>> # create shortcut for admin mode + >>> content_users=TSC.SiteItem.AdminMode.ContentAndUsers + + >>> # create a new SiteItem + >>> new_site = TSC.SiteItem(name='Tableau', content_url='tableau', admin_mode=content_users, user_quota=15, storage_quota=1000, disable_subscriptions=True) + + >>> # call the sites create method with the SiteItem + >>> new_site = server.sites.create(new_site) + + + """ if site_item.admin_mode: if site_item.admin_mode == SiteItem.AdminMode.ContentOnly and site_item.user_quota: error = "You cannot set admin_mode to ContentOnly and also set a user quota" @@ -123,33 +329,92 @@ def create(self, site_item: SiteItem) -> SiteItem: create_req = RequestFactory.Site.create_req(site_item, self.parent_srv) server_response = self.post_request(url, create_req) new_site = SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Created new site (ID: {0})".format(new_site.id)) + logger.info(f"Created new site (ID: {new_site.id})") return new_site @api(version="3.5") def encrypt_extracts(self, site_id: str) -> None: + """ + Encrypts all extracts on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#encrypt_extracts + + Parameters + ---------- + site_id : str + The site ID. + + Raises + ------ + ValueError + If the site ID is not defined. + + Examples + -------- + >>> server.sites.encrypt_extracts('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + """ if not site_id: error = "Site ID undefined." raise ValueError(error) - url = "{0}/{1}/encrypt-extracts".format(self.baseurl, site_id) + url = f"{self.baseurl}/{site_id}/encrypt-extracts" empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) @api(version="3.5") def decrypt_extracts(self, site_id: str) -> None: + """ + Decrypts all extracts on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#decrypt_extracts + + Parameters + ---------- + site_id : str + The site ID. + + Raises + ------ + ValueError + If the site ID is not defined. + + Examples + -------- + >>> server.sites.decrypt_extracts('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + """ if not site_id: error = "Site ID undefined." raise ValueError(error) - url = "{0}/{1}/decrypt-extracts".format(self.baseurl, site_id) + url = f"{self.baseurl}/{site_id}/decrypt-extracts" empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) @api(version="3.5") def re_encrypt_extracts(self, site_id: str) -> None: + """ + Reencrypt all extracts on a site with new encryption keys. If no site is + specified, extracts on the default site will be reencrypted. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#reencrypt_extracts + + Parameters + ---------- + site_id : str + The site ID. + + Raises + ------ + ValueError + If the site ID is not defined. + + Examples + -------- + >>> server.sites.re_encrypt_extracts('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + + """ if not site_id: error = "Site ID undefined." raise ValueError(error) - url = "{0}/{1}/reencrypt-extracts".format(self.baseurl, site_id) + url = f"{self.baseurl}/{site_id}/reencrypt-extracts" empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) diff --git a/tableauserverclient/server/endpoint/subscriptions_endpoint.py b/tableauserverclient/server/endpoint/subscriptions_endpoint.py index a9f2e7bf5..c9abc9b06 100644 --- a/tableauserverclient/server/endpoint/subscriptions_endpoint.py +++ b/tableauserverclient/server/endpoint/subscriptions_endpoint.py @@ -7,7 +7,7 @@ from tableauserverclient.helpers.logging import logger -from typing import List, Optional, TYPE_CHECKING, Tuple +from typing import Optional, TYPE_CHECKING if TYPE_CHECKING: from ..request_options import RequestOptions @@ -16,10 +16,10 @@ class Subscriptions(Endpoint): @property def baseurl(self) -> str: - return "{0}/sites/{1}/subscriptions".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/subscriptions" @api(version="2.3") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[SubscriptionItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[SubscriptionItem], PaginationItem]: logger.info("Querying all subscriptions for the site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -33,8 +33,8 @@ def get_by_id(self, subscription_id: str) -> SubscriptionItem: if not subscription_id: error = "No Subscription ID provided" raise ValueError(error) - logger.info("Querying a single subscription by id ({})".format(subscription_id)) - url = "{}/{}".format(self.baseurl, subscription_id) + logger.info(f"Querying a single subscription by id ({subscription_id})") + url = f"{self.baseurl}/{subscription_id}" server_response = self.get_request(url) return SubscriptionItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -43,7 +43,7 @@ def create(self, subscription_item: SubscriptionItem) -> SubscriptionItem: if not subscription_item: error = "No Susbcription provided" raise ValueError(error) - logger.info("Creating a subscription ({})".format(subscription_item)) + logger.info(f"Creating a subscription ({subscription_item})") url = self.baseurl create_req = RequestFactory.Subscription.create_req(subscription_item) server_response = self.post_request(url, create_req) @@ -54,17 +54,17 @@ def delete(self, subscription_id: str) -> None: if not subscription_id: error = "Subscription ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, subscription_id) + url = f"{self.baseurl}/{subscription_id}" self.delete_request(url) - logger.info("Deleted subscription (ID: {0})".format(subscription_id)) + logger.info(f"Deleted subscription (ID: {subscription_id})") @api(version="2.3") def update(self, subscription_item: SubscriptionItem) -> SubscriptionItem: if not subscription_item.id: error = "Subscription item missing ID. Subscription must be retrieved from server first." raise MissingRequiredFieldError(error) - url = "{0}/{1}".format(self.baseurl, subscription_item.id) + url = f"{self.baseurl}/{subscription_item.id}" update_req = RequestFactory.Subscription.update_req(subscription_item) server_response = self.put_request(url, update_req) - logger.info("Updated subscription item (ID: {0})".format(subscription_item.id)) + logger.info(f"Updated subscription item (ID: {subscription_item.id})") return SubscriptionItem.from_response(server_response.content, self.parent_srv.namespace)[0] diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index 36ef78c0a..120d3ba9c 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -1,5 +1,6 @@ import logging -from typing import Iterable, Set, Union +from typing import Union +from collections.abc import Iterable from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint from tableauserverclient.server.endpoint.endpoint import api, Endpoint @@ -15,14 +16,14 @@ class Tables(Endpoint, TaggingMixin[TableItem]): def __init__(self, parent_srv): - super(Tables, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "table") @property def baseurl(self): - return "{0}/sites/{1}/tables".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tables" @api(version="3.5") def get(self, req_options=None): @@ -39,8 +40,8 @@ def get_by_id(self, table_id): if not table_id: error = "table ID undefined." raise ValueError(error) - logger.info("Querying single table (ID: {0})".format(table_id)) - url = "{0}/{1}".format(self.baseurl, table_id) + logger.info(f"Querying single table (ID: {table_id})") + url = f"{self.baseurl}/{table_id}" server_response = self.get_request(url) return TableItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -49,9 +50,9 @@ def delete(self, table_id): if not table_id: error = "Database ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, table_id) + url = f"{self.baseurl}/{table_id}" self.delete_request(url) - logger.info("Deleted single table (ID: {0})".format(table_id)) + logger.info(f"Deleted single table (ID: {table_id})") @api(version="3.5") def update(self, table_item): @@ -59,10 +60,10 @@ def update(self, table_item): error = "table item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}".format(self.baseurl, table_item.id) + url = f"{self.baseurl}/{table_item.id}" update_req = RequestFactory.Table.update_req(table_item) server_response = self.put_request(url, update_req) - logger.info("Updated table item (ID: {0})".format(table_item.id)) + logger.info(f"Updated table item (ID: {table_item.id})") updated_table = TableItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_table @@ -80,10 +81,10 @@ def column_fetcher(): ) table_item._set_columns(column_fetcher) - logger.info("Populated columns for table (ID: {0}".format(table_item.id)) + logger.info(f"Populated columns for table (ID: {table_item.id}") def _get_columns_for_table(self, table_item, req_options=None): - url = "{0}/{1}/columns".format(self.baseurl, table_item.id) + url = f"{self.baseurl}/{table_item.id}/columns" server_response = self.get_request(url, req_options) columns = ColumnItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) @@ -91,12 +92,12 @@ def _get_columns_for_table(self, table_item, req_options=None): @api(version="3.5") def update_column(self, table_item, column_item): - url = "{0}/{1}/columns/{2}".format(self.baseurl, table_item.id, column_item.id) + url = f"{self.baseurl}/{table_item.id}/columns/{column_item.id}" update_req = RequestFactory.Column.update_req(column_item) server_response = self.put_request(url, update_req) column = ColumnItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Updated table item (ID: {0} & column item {1}".format(table_item.id, column_item.id)) + logger.info(f"Updated table item (ID: {table_item.id} & column item {column_item.id}") return column @api(version="3.5") @@ -128,7 +129,7 @@ def delete_dqw(self, item): self._data_quality_warnings.clear(item) @api(version="3.9") - def add_tags(self, item: Union[TableItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + def add_tags(self, item: Union[TableItem, str], tags: Union[Iterable[str], str]) -> set[str]: return super().add_tags(item, tags) @api(version="3.9") diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index a727a515f..eb82c43bc 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -1,5 +1,5 @@ import logging -from typing import List, Optional, Tuple, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from tableauserverclient.server.endpoint.endpoint import Endpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError @@ -15,7 +15,7 @@ class Tasks(Endpoint): @property def baseurl(self) -> str: - return "{0}/sites/{1}/tasks".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks" def __normalize_task_type(self, task_type: str) -> str: """ @@ -23,20 +23,20 @@ def __normalize_task_type(self, task_type: str) -> str: It is different than the tag "extractRefresh" used in the request body. """ if task_type == TaskItem.Type.ExtractRefresh: - return "{}es".format(task_type) + return f"{task_type}es" else: return task_type @api(version="2.6") def get( self, req_options: Optional["RequestOptions"] = None, task_type: str = TaskItem.Type.ExtractRefresh - ) -> Tuple[List[TaskItem], PaginationItem]: + ) -> tuple[list[TaskItem], PaginationItem]: if task_type == TaskItem.Type.DataAcceleration: self.parent_srv.assert_at_least_version("3.8", "Data Acceleration Tasks") logger.info("Querying all %s tasks for the site", task_type) - url = "{0}/{1}".format(self.baseurl, self.__normalize_task_type(task_type)) + url = f"{self.baseurl}/{self.__normalize_task_type(task_type)}" server_response = self.get_request(url, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) @@ -63,7 +63,7 @@ def create(self, extract_item: TaskItem) -> TaskItem: error = "No extract refresh provided" raise ValueError(error) logger.info("Creating an extract refresh %s", extract_item) - url = "{0}/{1}".format(self.baseurl, self.__normalize_task_type(TaskItem.Type.ExtractRefresh)) + url = f"{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 @@ -74,7 +74,7 @@ def run(self, task_item: TaskItem) -> bytes: error = "Task item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}/{2}/runNow".format( + url = "{}/{}/{}/runNow".format( self.baseurl, self.__normalize_task_type(TaskItem.Type.ExtractRefresh), task_item.id, @@ -92,6 +92,6 @@ def delete(self, task_id: str, task_type: str = TaskItem.Type.ExtractRefresh) -> if not task_id: error = "No Task ID provided" raise ValueError(error) - url = "{0}/{1}/{2}".format(self.baseurl, self.__normalize_task_type(task_type), task_id) + url = f"{self.baseurl}/{self.__normalize_task_type(task_type)}/{task_id}" self.delete_request(url) logger.info("Deleted single task (ID: %s)", task_id) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index c4b6418b7..d81907ae9 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -1,6 +1,6 @@ import copy import logging -from typing import List, Optional, Tuple +from typing import Optional from tableauserverclient.server.query import QuerySet @@ -14,13 +14,75 @@ class Users(QuerysetEndpoint[UserItem]): + """ + The user resources for Tableau Server are defined in the UserItem class. + The class corresponds to the user resources you can access using the + Tableau Server REST API. The user methods are based upon the endpoints for + users in the REST API and operate on the UserItem class. Only server and + site administrators can access the user resources. + """ + @property def baseurl(self) -> str: - return "{0}/sites/{1}/users".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/users" # Gets all users @api(version="2.0") - def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[UserItem], PaginationItem]: + def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[UserItem], PaginationItem]: + """ + Query all users on the site. Request is paginated and returns a subset of users. + By default, the request returns the first 100 users on the site. + + Parameters + ---------- + req_options : Optional[RequestOptions] + Optional request options to filter and sort the results. + + Returns + ------- + tuple[list[UserItem], PaginationItem] + Returns a tuple with a list of UserItem objects and a PaginationItem object. + + Raises + ------ + ServerResponseError + code: 400006 + summary: Invalid page number + detail: The page number is not an integer, is less than one, or is + greater than the final page number for users at the requested + page size. + + ServerResponseError + code: 400007 + summary: Invalid page size + detail: The page size parameter is not an integer, is less than one. + + ServerResponseError + code: 403014 + summary: Page size limit exceeded + detail: The specified page size is larger than the maximum page size + + ServerResponseError + code: 404000 + summary: Site not found + detail: The site ID in the URI doesn't correspond to an existing site. + + ServerResponseError + code: 405000 + summary: Invalid request method + detail: Request type was not GET. + + Examples + -------- + >>> import tableauserverclient as TSC + >>> tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') + >>> server = TSC.Server('https://SERVERURL') + + >>> with server.auth.sign_in(tableau_auth): + >>> users_page, pagination_item = server.users.get() + >>> print("\nThere are {} user on site: ".format(pagination_item.total_available)) + >>> print([user.name for user in users_page]) + """ logger.info("Querying all users on site") if req_options is None: @@ -36,55 +98,253 @@ def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[UserIt # Gets 1 user by id @api(version="2.0") def get_by_id(self, user_id: str) -> UserItem: + """ + Query a single user by ID. + + Parameters + ---------- + user_id : str + The ID of the user to query. + + Returns + ------- + UserItem + The user item that was queried. + + Raises + ------ + ValueError + If the user ID is not specified. + + ServerResponseError + code: 404000 + summary: Site not found + detail: The site ID in the URI doesn't correspond to an existing site. + + ServerResponseError + code: 403133 + summary: Query user permissions forbidden + detail: The user does not have permissions to query user information + for other users + + ServerResponseError + code: 404002 + summary: User not found + detail: The user ID in the URI doesn't correspond to an existing user. + + ServerResponseError + code: 405000 + summary: Invalid request method + detail: Request type was not GET. + + Examples + -------- + >>> user1 = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') + """ if not user_id: error = "User ID undefined." raise ValueError(error) - logger.info("Querying single user (ID: {0})".format(user_id)) - url = "{0}/{1}".format(self.baseurl, user_id) + logger.info(f"Querying single user (ID: {user_id})") + url = f"{self.baseurl}/{user_id}" server_response = self.get_request(url) return UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() # Update user @api(version="2.0") def update(self, user_item: UserItem, password: Optional[str] = None) -> UserItem: + """ + Modifies information about the specified user. + + If Tableau Server is configured to use local authentication, you can + update the user's name, email address, password, or site role. + + If Tableau Server is configured to use Active Directory + authentication, you can change the user's display name (full name), + email address, and site role. However, if you synchronize the user with + Active Directory, the display name and email address will be + overwritten with the information that's in Active Directory. + + For Tableau Cloud, you can update the site role for a user, but you + cannot update or change a user's password, user name (email address), + or full name. + + Parameters + ---------- + user_item : UserItem + The user item to update. + + password : Optional[str] + The new password for the user. + + Returns + ------- + UserItem + The user item that was updated. + + Raises + ------ + MissingRequiredFieldError + If the user item is missing an ID. + + Examples + -------- + >>> user = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') + >>> user.fullname = 'New Full Name' + >>> updated_user = server.users.update(user) + + """ if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" update_req = RequestFactory.User.update_req(user_item, password) server_response = self.put_request(url, update_req) - logger.info("Updated user item (ID: {0})".format(user_item.id)) + logger.info(f"Updated user item (ID: {user_item.id})") updated_item = copy.copy(user_item) return updated_item._parse_common_tags(server_response.content, self.parent_srv.namespace) # Delete 1 user by id @api(version="2.0") def remove(self, user_id: str, map_assets_to: Optional[str] = None) -> None: + """ + Removes a user from the site. You can also specify a user to map the + assets to when you remove the user. + + Parameters + ---------- + user_id : str + The ID of the user to remove. + + map_assets_to : Optional[str] + The ID of the user to map the assets to when you remove the user. + + Returns + ------- + None + + Raises + ------ + ValueError + If the user ID is not specified. + + Examples + -------- + >>> server.users.remove('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') + """ if not user_id: error = "User ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, user_id) + url = f"{self.baseurl}/{user_id}" if map_assets_to is not None: url += f"?mapAssetsTo={map_assets_to}" self.delete_request(url) - logger.info("Removed single user (ID: {0})".format(user_id)) + logger.info(f"Removed single user (ID: {user_id})") # Add new user to site @api(version="2.0") def add(self, user_item: UserItem) -> UserItem: + """ + Adds the user to the site. + + To add a new user to the site you need to first create a new user_item + (from UserItem class). When you create a new user, you specify the name + of the user and their site role. For Tableau Cloud, you also specify + the auth_setting attribute in your request. When you add user to + Tableau Cloud, the name of the user must be the email address that is + used to sign in to Tableau Cloud. After you add a user, Tableau Cloud + sends the user an email invitation. The user can click the link in the + invitation to sign in and update their full name and password. + + Parameters + ---------- + user_item : UserItem + The user item to add to the site. + + Returns + ------- + UserItem + The user item that was added to the site with attributes from the + site populated. + + Raises + ------ + ValueError + If the user item is missing a name + + ValueError + If the user item is missing a site role + + ServerResponseError + code: 400000 + summary: Bad Request + detail: The content of the request body is missing or incomplete, or + contains malformed XML. + + ServerResponseError + code: 400003 + summary: Bad Request + detail: The user authentication setting ServerDefault is not + supported for you site. Try again using TableauIDWithMFA instead. + + ServerResponseError + code: 400013 + summary: Invalid site role + detail: The value of the siteRole attribute must be Explorer, + ExplorerCanPublish, SiteAdministratorCreator, + SiteAdministratorExplorer, Unlicensed, or Viewer. + + ServerResponseError + code: 404000 + summary: Site not found + detail: The site ID in the URI doesn't correspond to an existing site. + + ServerResponseError + code: 404002 + summary: User not found + detail: The server is configured to use Active Directory for + authentication, and the username specified in the request body + doesn't match an existing user in Active Directory. + + ServerResponseError + code: 405000 + summary: Invalid request method + detail: Request type was not POST. + + ServerResponseError + code: 409000 + summary: User conflict + detail: The specified user already exists on the site. + + ServerResponseError + code: 409005 + summary: Guest user conflict + detail: The Tableau Server API doesn't allow adding a user with the + guest role to a site. + + + Examples + -------- + >>> import tableauserverclient as TSC + >>> server = TSC.Server('https://SERVERURL') + >>> # Login to the server + + >>> new_user = TSC.UserItem(name='new_user', site_role=TSC.UserItem.Role.Unlicensed) + >>> new_user = server.users.add(new_user) + + """ url = self.baseurl - logger.info("Add user {}".format(user_item.name)) + logger.info(f"Add user {user_item.name}") add_req = RequestFactory.User.add_req(user_item) server_response = self.post_request(url, add_req) logger.info(server_response) new_user = UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() - logger.info("Added new user (ID: {0})".format(new_user.id)) + logger.info(f"Added new user (ID: {new_user.id})") return new_user # Add new users to site. This does not actually perform a bulk action, it's syntactic sugar @api(version="2.0") - def add_all(self, users: List[UserItem]): + def add_all(self, users: list[UserItem]): created = [] failed = [] for user in users: @@ -98,7 +358,7 @@ def add_all(self, users: List[UserItem]): # helping the user by parsing a file they could have used to add users through the UI # line format: Username [required], password, display name, license, admin, publish @api(version="2.0") - def create_from_file(self, filepath: str) -> Tuple[List[UserItem], List[Tuple[UserItem, ServerResponseError]]]: + def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[UserItem, ServerResponseError]]]: created = [] failed = [] if not filepath.find("csv"): @@ -122,6 +382,42 @@ def create_from_file(self, filepath: str) -> Tuple[List[UserItem], List[Tuple[Us # Get workbooks for user @api(version="2.0") def populate_workbooks(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: + """ + Returns information about the workbooks that the specified user owns + and has Read (view) permissions for. + + This method retrieves the workbook information for the specified user. + The REST API is designed to return only the information you ask for + explicitly. When you query for all the users, the workbook information + for each user is not included. Use this method to retrieve information + about the workbooks that the user owns or has Read (view) permissions. + The method adds the list of workbooks to the user item object + (user_item.workbooks). + + Parameters + ---------- + user_item : UserItem + The user item to populate workbooks for. + + req_options : Optional[RequestOptions] + Optional request options to filter and sort the results. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the user item is missing an ID. + + Examples + -------- + >>> user = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') + >>> server.users.populate_workbooks(user) + >>> for wb in user.workbooks: + >>> print(wb.name) + """ if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) @@ -133,20 +429,71 @@ def wb_pager(): def _get_wbs_for_user( self, user_item: UserItem, req_options: Optional[RequestOptions] = None - ) -> Tuple[List[WorkbookItem], PaginationItem]: - url = "{0}/{1}/workbooks".format(self.baseurl, user_item.id) + ) -> tuple[list[WorkbookItem], PaginationItem]: + url = f"{self.baseurl}/{user_item.id}/workbooks" server_response = self.get_request(url, req_options) - logger.info("Populated workbooks for user (ID: {0})".format(user_item.id)) + logger.info(f"Populated workbooks for user (ID: {user_item.id})") workbook_item = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) return workbook_item, pagination_item def populate_favorites(self, user_item: UserItem) -> None: + """ + Populate the favorites for the user. + + Parameters + ---------- + user_item : UserItem + The user item to populate favorites for. + + Returns + ------- + None + + Examples + -------- + >>> import tableauserverclient as TSC + >>> server = TSC.Server('https://SERVERURL') + >>> # Login to the server + + >>> user = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') + >>> server.users.populate_favorites(user) + >>> for obj_type, items in user.favorites.items(): + >>> print(f"Favorites for {obj_type}:") + >>> for item in items: + >>> print(item.name) + """ self.parent_srv.favorites.get(user_item) # Get groups for user @api(version="3.7") def populate_groups(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: + """ + Populate the groups for the user. + + Parameters + ---------- + user_item : UserItem + The user item to populate groups for. + + req_options : Optional[RequestOptions] + Optional request options to filter and sort the results. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the user item is missing an ID. + + Examples + -------- + >>> server.users.populate_groups(user) + >>> for group in user.groups: + >>> print(group.name) + """ if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) @@ -161,10 +508,10 @@ def groups_for_user_pager(): def _get_groups_for_user( self, user_item: UserItem, req_options: Optional[RequestOptions] = None - ) -> Tuple[List[GroupItem], PaginationItem]: - url = "{0}/{1}/groups".format(self.baseurl, user_item.id) + ) -> tuple[list[GroupItem], PaginationItem]: + url = f"{self.baseurl}/{user_item.id}/groups" server_response = self.get_request(url, req_options) - logger.info("Populated groups for user (ID: {0})".format(user_item.id)) + logger.info(f"Populated groups for user (ID: {user_item.id})") group_item = GroupItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) return group_item, pagination_item diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index f2ccf658e..3709fc41d 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -11,7 +11,8 @@ from tableauserverclient.helpers.logging import logger -from typing import Iterable, Iterator, List, Optional, Set, Tuple, TYPE_CHECKING, Union +from typing import Optional, TYPE_CHECKING, Union +from collections.abc import Iterable, Iterator if TYPE_CHECKING: from tableauserverclient.server.request_options import ( @@ -25,22 +26,22 @@ class Views(QuerysetEndpoint[ViewItem], TaggingMixin[ViewItem]): def __init__(self, parent_srv): - super(Views, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) # Used because populate_preview_image functionaliy requires workbook endpoint @property def siteurl(self) -> str: - return "{0}/sites/{1}".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}" @property def baseurl(self) -> str: - return "{0}/views".format(self.siteurl) + return f"{self.siteurl}/views" @api(version="2.2") def get( self, req_options: Optional["RequestOptions"] = None, usage: bool = False - ) -> Tuple[List[ViewItem], PaginationItem]: + ) -> tuple[list[ViewItem], PaginationItem]: logger.info("Querying all views on site") url = self.baseurl if usage: @@ -55,8 +56,8 @@ 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) + logger.info(f"Querying single view (ID: {view_id})") + url = f"{self.baseurl}/{view_id}" if usage: url += "?includeUsageStatistics=true" server_response = self.get_request(url) @@ -72,10 +73,10 @@ def image_fetcher(): return self._get_preview_for_view(view_item) view_item._set_preview_image(image_fetcher) - logger.info("Populated preview image for view (ID: {0})".format(view_item.id)) + logger.info(f"Populated preview image for view (ID: {view_item.id})") def _get_preview_for_view(self, view_item: ViewItem) -> bytes: - url = "{0}/workbooks/{1}/views/{2}/previewImage".format(self.siteurl, view_item.workbook_id, view_item.id) + url = f"{self.siteurl}/workbooks/{view_item.workbook_id}/views/{view_item.id}/previewImage" server_response = self.get_request(url) image = server_response.content return image @@ -90,10 +91,10 @@ def image_fetcher(): return self._get_view_image(view_item, req_options) view_item._set_image(image_fetcher) - logger.info("Populated image for view (ID: {0})".format(view_item.id)) + logger.info(f"Populated image for view (ID: {view_item.id})") def _get_view_image(self, view_item: ViewItem, req_options: Optional["ImageRequestOptions"]) -> bytes: - url = "{0}/{1}/image".format(self.baseurl, view_item.id) + url = f"{self.baseurl}/{view_item.id}/image" server_response = self.get_request(url, req_options) image = server_response.content return image @@ -108,10 +109,10 @@ def pdf_fetcher(): return self._get_view_pdf(view_item, req_options) view_item._set_pdf(pdf_fetcher) - logger.info("Populated pdf for view (ID: {0})".format(view_item.id)) + logger.info(f"Populated pdf for view (ID: {view_item.id})") def _get_view_pdf(self, view_item: ViewItem, req_options: Optional["PDFRequestOptions"]) -> bytes: - url = "{0}/{1}/pdf".format(self.baseurl, view_item.id) + url = f"{self.baseurl}/{view_item.id}/pdf" server_response = self.get_request(url, req_options) pdf = server_response.content return pdf @@ -126,10 +127,10 @@ def csv_fetcher(): return self._get_view_csv(view_item, req_options) view_item._set_csv(csv_fetcher) - logger.info("Populated csv for view (ID: {0})".format(view_item.id)) + logger.info(f"Populated csv for view (ID: {view_item.id})") def _get_view_csv(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"]) -> Iterator[bytes]: - url = "{0}/{1}/data".format(self.baseurl, view_item.id) + url = f"{self.baseurl}/{view_item.id}/data" with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: yield from server_response.iter_content(1024) @@ -144,10 +145,10 @@ def excel_fetcher(): return self._get_view_excel(view_item, req_options) view_item._set_excel(excel_fetcher) - logger.info("Populated excel for view (ID: {0})".format(view_item.id)) + logger.info(f"Populated excel for view (ID: {view_item.id})") def _get_view_excel(self, view_item: ViewItem, req_options: Optional["ExcelRequestOptions"]) -> Iterator[bytes]: - url = "{0}/{1}/crosstab/excel".format(self.baseurl, view_item.id) + url = f"{self.baseurl}/{view_item.id}/crosstab/excel" with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: yield from server_response.iter_content(1024) @@ -176,7 +177,7 @@ def update(self, view_item: ViewItem) -> ViewItem: return view_item @api(version="1.0") - def add_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + def add_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) -> set[str]: return super().add_tags(item, tags) @api(version="1.0") diff --git a/tableauserverclient/server/endpoint/virtual_connections_endpoint.py b/tableauserverclient/server/endpoint/virtual_connections_endpoint.py index f71db00cc..944b72502 100644 --- a/tableauserverclient/server/endpoint/virtual_connections_endpoint.py +++ b/tableauserverclient/server/endpoint/virtual_connections_endpoint.py @@ -1,7 +1,8 @@ from functools import partial import json from pathlib import Path -from typing import Iterable, List, Optional, Set, TYPE_CHECKING, Tuple, Union +from typing import Optional, TYPE_CHECKING, Union +from collections.abc import Iterable from tableauserverclient.models.connection_item import ConnectionItem from tableauserverclient.models.pagination_item import PaginationItem @@ -28,7 +29,7 @@ def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/virtualConnections" @api(version="3.18") - def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[VirtualConnectionItem], PaginationItem]: + def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[VirtualConnectionItem], PaginationItem]: server_response = self.get_request(self.baseurl, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) virtual_connections = VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace) @@ -44,7 +45,7 @@ def _connection_fetcher(): def _get_virtual_database_connections( self, virtual_connection: VirtualConnectionItem, req_options: Optional[RequestOptions] = None - ) -> Tuple[List[ConnectionItem], PaginationItem]: + ) -> tuple[list[ConnectionItem], PaginationItem]: server_response = self.get_request(f"{self.baseurl}/{virtual_connection.id}/connections", req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) @@ -83,7 +84,7 @@ def update(self, virtual_connection: VirtualConnectionItem) -> VirtualConnection @api(version="3.23") def get_revisions( self, virtual_connection: VirtualConnectionItem, req_options: Optional[RequestOptions] = None - ) -> Tuple[List[RevisionItem], PaginationItem]: + ) -> tuple[list[RevisionItem], PaginationItem]: server_response = self.get_request(f"{self.baseurl}/{virtual_connection.id}/revisions", req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, virtual_connection) @@ -159,7 +160,7 @@ def delete_permission(self, item, capability_item): @api(version="3.23") def add_tags( self, virtual_connection: Union[VirtualConnectionItem, str], tags: Union[Iterable[str], str] - ) -> Set[str]: + ) -> set[str]: return super().add_tags(virtual_connection, tags) @api(version="3.23") diff --git a/tableauserverclient/server/endpoint/webhooks_endpoint.py b/tableauserverclient/server/endpoint/webhooks_endpoint.py index 597f9c425..06643f99d 100644 --- a/tableauserverclient/server/endpoint/webhooks_endpoint.py +++ b/tableauserverclient/server/endpoint/webhooks_endpoint.py @@ -6,7 +6,7 @@ from tableauserverclient.helpers.logging import logger -from typing import List, Optional, TYPE_CHECKING, Tuple +from typing import Optional, TYPE_CHECKING if TYPE_CHECKING: from ..server import Server @@ -15,14 +15,14 @@ class Webhooks(Endpoint): def __init__(self, parent_srv: "Server") -> None: - super(Webhooks, self).__init__(parent_srv) + super().__init__(parent_srv) @property def baseurl(self) -> str: - return "{0}/sites/{1}/webhooks".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/webhooks" @api(version="3.6") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[WebhookItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[WebhookItem], PaginationItem]: logger.info("Querying all Webhooks on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -35,8 +35,8 @@ def get_by_id(self, webhook_id: str) -> WebhookItem: if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) - logger.info("Querying single webhook (ID: {0})".format(webhook_id)) - url = "{0}/{1}".format(self.baseurl, webhook_id) + logger.info(f"Querying single webhook (ID: {webhook_id})") + url = f"{self.baseurl}/{webhook_id}" server_response = self.get_request(url) return WebhookItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -45,9 +45,9 @@ def delete(self, webhook_id: str) -> None: if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, webhook_id) + url = f"{self.baseurl}/{webhook_id}" self.delete_request(url) - logger.info("Deleted single webhook (ID: {0})".format(webhook_id)) + logger.info(f"Deleted single webhook (ID: {webhook_id})") @api(version="3.6") def create(self, webhook_item: WebhookItem) -> WebhookItem: @@ -56,7 +56,7 @@ def create(self, webhook_item: WebhookItem) -> WebhookItem: server_response = self.post_request(url, create_req) new_webhook = WebhookItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Created new webhook (ID: {0})".format(new_webhook.id)) + logger.info(f"Created new webhook (ID: {new_webhook.id})") return new_webhook @api(version="3.6") @@ -64,7 +64,7 @@ def test(self, webhook_id: str): if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) - url = "{0}/{1}/test".format(self.baseurl, webhook_id) + url = f"{self.baseurl}/{webhook_id}/test" testOutcome = self.get_request(url) - logger.info("Testing webhook (ID: {0} returned {1})".format(webhook_id, testOutcome)) + logger.info(f"Testing webhook (ID: {webhook_id} returned {testOutcome})") return testOutcome diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index da6eda3de..460017d1a 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -7,6 +7,7 @@ from pathlib import Path from tableauserverclient.helpers.headers import fix_filename +from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.server.query import QuerySet from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api, parameter_added_in @@ -25,15 +26,11 @@ from tableauserverclient.server import RequestFactory from typing import ( - Iterable, - List, Optional, - Sequence, - Set, - Tuple, TYPE_CHECKING, Union, ) +from collections.abc import Iterable, Sequence if TYPE_CHECKING: from tableauserverclient.server import Server @@ -61,18 +58,34 @@ class Workbooks(QuerysetEndpoint[WorkbookItem], TaggingMixin[WorkbookItem]): def __init__(self, parent_srv: "Server") -> None: - super(Workbooks, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) return None @property def baseurl(self) -> str: - return "{0}/sites/{1}/workbooks".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/workbooks" # Get all workbooks on site @api(version="2.0") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[WorkbookItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[WorkbookItem], PaginationItem]: + """ + Queries the server and returns information about the workbooks the site. + + Parameters + ---------- + req_options : RequestOptions, optional + (Optional) You can pass the method a request object that contains + additional parameters to filter the request. For example, if you + were searching for a specific workbook, you could specify the name + of the workbook or the name of the owner. + + Returns + ------- + Tuple containing one page's worth of workbook items and pagination + information. + """ logger.info("Querying all workbooks on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -83,18 +96,44 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[Work # Get 1 workbook @api(version="2.0") def get_by_id(self, workbook_id: str) -> WorkbookItem: + """ + Returns information about the specified workbook on the site. + + Parameters + ---------- + workbook_id : str + The workbook ID. + + Returns + ------- + WorkbookItem + The workbook item. + """ if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) - logger.info("Querying single workbook (ID: {0})".format(workbook_id)) - url = "{0}/{1}".format(self.baseurl, workbook_id) + logger.info(f"Querying single workbook (ID: {workbook_id})") + url = f"{self.baseurl}/{workbook_id}" server_response = self.get_request(url) return WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="2.8") def refresh(self, workbook_item: Union[WorkbookItem, str]) -> JobItem: + """ + Refreshes the extract of an existing workbook. + + Parameters + ---------- + workbook_item : WorkbookItem | str + The workbook item or workbook ID. + + Returns + ------- + JobItem + The job item. + """ id_ = getattr(workbook_item, "id", workbook_item) - url = "{0}/{1}/refresh".format(self.baseurl, id_) + url = f"{self.baseurl}/{id_}/refresh" empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -107,10 +146,37 @@ def create_extract( workbook_item: WorkbookItem, encrypt: bool = False, includeAll: bool = True, - datasources: Optional[List["DatasourceItem"]] = None, + datasources: Optional[list["DatasourceItem"]] = None, ) -> JobItem: + """ + Create one or more extracts on 1 workbook, optionally encrypted. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#create_extracts_for_workbook + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to create extracts for. + + encrypt : bool, default False + Set to True to encrypt the extracts. + + includeAll : bool, default True + If True, all data sources in the workbook will have an extract + created for them. If False, then a data source must be supplied in + the request. + + datasources : list[DatasourceItem] | None + List of DatasourceItem objects for the data sources to create + extracts for. Only required if includeAll is False. + + Returns + ------- + JobItem + The job item for the extract creation. + """ id_ = getattr(workbook_item, "id", workbook_item) - url = "{0}/{1}/createExtract?encrypt={2}".format(self.baseurl, id_, encrypt) + url = f"{self.baseurl}/{id_}/createExtract?encrypt={encrypt}" datasource_req = RequestFactory.Workbook.embedded_extract_req(includeAll, datasources) server_response = self.post_request(url, datasource_req) @@ -120,8 +186,31 @@ def create_extract( # delete all the extracts on 1 workbook @api(version="3.3") def delete_extract(self, workbook_item: WorkbookItem, includeAll: bool = True, datasources=None) -> JobItem: + """ + Delete all extracts of embedded datasources on 1 workbook. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#delete_extracts_from_workbook + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to delete extracts from. + + includeAll : bool, default True + If True, all data sources in the workbook will have their extracts + deleted. If False, then a data source must be supplied in the + request. + + datasources : list[DatasourceItem] | None + List of DatasourceItem objects for the data sources to delete + extracts from. Only required if includeAll is False. + + Returns + ------- + JobItem + """ id_ = getattr(workbook_item, "id", workbook_item) - url = "{0}/{1}/deleteExtract".format(self.baseurl, id_) + url = f"{self.baseurl}/{id_}/deleteExtract" datasource_req = RequestFactory.Workbook.embedded_extract_req(includeAll, datasources) server_response = self.post_request(url, datasource_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -130,12 +219,24 @@ def delete_extract(self, workbook_item: WorkbookItem, includeAll: bool = True, d # Delete 1 workbook by id @api(version="2.0") def delete(self, workbook_id: str) -> None: + """ + Deletes a workbook with the specified ID. + + Parameters + ---------- + workbook_id : str + The workbook ID. + + Returns + ------- + None + """ if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, workbook_id) + url = f"{self.baseurl}/{workbook_id}" self.delete_request(url) - logger.info("Deleted single workbook (ID: {0})".format(workbook_id)) + logger.info(f"Deleted single workbook (ID: {workbook_id})") # Update workbook @api(version="2.0") @@ -145,6 +246,29 @@ def update( workbook_item: WorkbookItem, include_view_acceleration_status: bool = False, ) -> WorkbookItem: + """ + Modifies an existing workbook. Use this method to change the owner or + the project that the workbook belongs to, or to change whether the + workbook shows views in tabs. The workbook item must include the + workbook ID and overrides the existing settings. + + See https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#update_workbook + for a list of fields that can be updated. + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to update. ID is required. Other fields are + optional. Any fields that are not specified will not be changed. + + include_view_acceleration_status : bool, default False + Set to True to include the view acceleration status in the response. + + Returns + ------- + WorkbookItem + The updated workbook item. + """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -152,27 +276,47 @@ def update( self.update_tags(workbook_item) # Update the workbook itself - url = "{0}/{1}".format(self.baseurl, workbook_item.id) + url = f"{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)) + logger.info(f"Updated workbook item (ID: {workbook_item.id})") updated_workbook = copy.copy(workbook_item) return updated_workbook._parse_common_tags(server_response.content, self.parent_srv.namespace) # Update workbook_connection @api(version="2.3") def update_connection(self, workbook_item: WorkbookItem, connection_item: ConnectionItem) -> ConnectionItem: - url = "{0}/{1}/connections/{2}".format(self.baseurl, workbook_item.id, connection_item.id) + """ + Updates a workbook connection information (server addres, server port, + user name, and password). + + The workbook connections must be populated before the strings can be + updated. + + Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#update_workbook_connection + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to update. + + connection_item : ConnectionItem + The connection item to update. + + Returns + ------- + ConnectionItem + The updated connection item. + """ + url = f"{self.baseurl}/{workbook_item.id}/connections/{connection_item.id}" update_req = RequestFactory.Connection.update_req(connection_item) server_response = self.put_request(url, update_req) connection = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info( - "Updated workbook item (ID: {0} & connection item {1})".format(workbook_item.id, connection_item.id) - ) + logger.info(f"Updated workbook item (ID: {workbook_item.id} & connection item {connection_item.id})") return connection # Download workbook contents with option of passing in filepath @@ -185,6 +329,34 @@ def download( filepath: Optional[PathOrFileW] = None, include_extract: bool = True, ) -> PathOrFileW: + """ + Downloads a workbook to the specified directory (optional). + + Parameters + ---------- + workbook_id : str + The workbook ID. + + filepath : Path or File object, optional + Downloads the file to the location you specify. If no location is + specified, the file is downloaded to the current working directory. + The default is Filepath=None. + + include_extract : bool, default True + Set to False to exclude the extract from the download. The default + is True. + + Returns + ------- + Path or File object + The path to the downloaded workbook or the file object. + + Raises + ------ + ValueError + If the workbook ID is not defined. + """ + return self.download_revision( workbook_id, None, @@ -195,18 +367,48 @@ def download( # Get all views of workbook @api(version="2.0") def populate_views(self, workbook_item: WorkbookItem, usage: bool = False) -> None: + """ + Populates (or gets) a list of views for a workbook. + + You must first call this method to populate views before you can iterate + through the views. + + This method retrieves the view information for the specified workbook. + The REST API is designed to return only the information you ask for + explicitly. When you query for all the workbooks, the view information + is not included. Use this method to retrieve the views. The method adds + the list of views to the workbook item (workbook_item.views). This is a + list of ViewItem. + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate views for. + + usage : bool, default False + Set to True to include usage statistics for each view. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) - def view_fetcher() -> List[ViewItem]: + def view_fetcher() -> list[ViewItem]: return self._get_views_for_workbook(workbook_item, usage) workbook_item._set_views(view_fetcher) - logger.info("Populated views for workbook (ID: {0})".format(workbook_item.id)) + logger.info(f"Populated views for workbook (ID: {workbook_item.id})") - def _get_views_for_workbook(self, workbook_item: WorkbookItem, usage: bool) -> List[ViewItem]: - url = "{0}/{1}/views".format(self.baseurl, workbook_item.id) + def _get_views_for_workbook(self, workbook_item: WorkbookItem, usage: bool) -> list[ViewItem]: + url = f"{self.baseurl}/{workbook_item.id}/views" if usage: url += "?includeUsageStatistics=true" server_response = self.get_request(url) @@ -220,6 +422,36 @@ def _get_views_for_workbook(self, workbook_item: WorkbookItem, usage: bool) -> L # Get all connections of workbook @api(version="2.0") def populate_connections(self, workbook_item: WorkbookItem) -> None: + """ + Populates a list of data source connections for the specified workbook. + + You must populate connections before you can iterate through the + connections. + + This method retrieves the data source connection information for the + specified workbook. The REST API is designed to return only the + information you ask for explicitly. When you query all the workbooks, + the data source connection information is not included. Use this method + to retrieve the connection information for any data sources used by the + workbook. The method adds the list of data connections to the workbook + item (workbook_item.connections). This is a list of ConnectionItem. + + REST API docs: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_workbook_connections + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate connections for. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -228,12 +460,12 @@ def connection_fetcher(): return self._get_workbook_connections(workbook_item) workbook_item._set_connections(connection_fetcher) - logger.info("Populated connections for workbook (ID: {0})".format(workbook_item.id)) + logger.info(f"Populated connections for workbook (ID: {workbook_item.id})") def _get_workbook_connections( self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None - ) -> List[ConnectionItem]: - url = "{0}/{1}/connections".format(self.baseurl, workbook_item.id) + ) -> list[ConnectionItem]: + url = f"{self.baseurl}/{workbook_item.id}/connections" server_response = self.get_request(url, req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) return connections @@ -241,6 +473,34 @@ def _get_workbook_connections( # Get the pdf of the entire workbook if its tabs are enabled, pdf of the default view if its tabs are disabled @api(version="3.4") def populate_pdf(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None) -> None: + """ + Populates the PDF for the specified workbook item. + + This method populates a PDF with image(s) of the workbook view(s) you + specify. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#download_workbook_pdf + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate the PDF for. + + req_options : RequestOptions, optional + (Optional) You can pass in request options to specify the page type + and orientation of the PDF content, as well as the maximum age of + the PDF rendered on the server. See PDFRequestOptions class for more + details. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID." raise MissingRequiredFieldError(error) @@ -249,16 +509,46 @@ def pdf_fetcher() -> bytes: return self._get_wb_pdf(workbook_item, req_options) workbook_item._set_pdf(pdf_fetcher) - logger.info("Populated pdf for workbook (ID: {0})".format(workbook_item.id)) + logger.info(f"Populated pdf for workbook (ID: {workbook_item.id})") def _get_wb_pdf(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"]) -> bytes: - url = "{0}/{1}/pdf".format(self.baseurl, workbook_item.id) + url = f"{self.baseurl}/{workbook_item.id}/pdf" server_response = self.get_request(url, req_options) pdf = server_response.content return pdf @api(version="3.8") def populate_powerpoint(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None) -> None: + """ + Populates the PowerPoint for the specified workbook item. + + This method populates a PowerPoint with image(s) of the workbook view(s) you + specify. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#download_workbook_powerpoint + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate the PDF for. + + req_options : RequestOptions, optional + (Optional) You can pass in request options to specify the maximum + number of minutes a workbook .pptx will be cached before being + refreshed. To prevent multiple .pptx requests from overloading the + server, the shortest interval you can set is one minute. There is no + maximum value, but the server job enacting the caching action may + expire before a long cache period is reached. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID." raise MissingRequiredFieldError(error) @@ -267,10 +557,10 @@ def pptx_fetcher() -> bytes: return self._get_wb_pptx(workbook_item, req_options) workbook_item._set_powerpoint(pptx_fetcher) - logger.info("Populated powerpoint for workbook (ID: {0})".format(workbook_item.id)) + logger.info(f"Populated powerpoint for workbook (ID: {workbook_item.id})") def _get_wb_pptx(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"]) -> bytes: - url = "{0}/{1}/powerpoint".format(self.baseurl, workbook_item.id) + url = f"{self.baseurl}/{workbook_item.id}/powerpoint" server_response = self.get_request(url, req_options) pptx = server_response.content return pptx @@ -278,6 +568,26 @@ def _get_wb_pptx(self, workbook_item: WorkbookItem, req_options: Optional["Reque # Get preview image of workbook @api(version="2.0") def populate_preview_image(self, workbook_item: WorkbookItem) -> None: + """ + This method gets the preview image (thumbnail) for the specified workbook item. + + This method uses the workbook's ID to get the preview image. The method + adds the preview image to the workbook item (workbook_item.preview_image). + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate the preview image for. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -286,24 +596,75 @@ def image_fetcher() -> bytes: return self._get_wb_preview_image(workbook_item) workbook_item._set_preview_image(image_fetcher) - logger.info("Populated preview image for workbook (ID: {0})".format(workbook_item.id)) + logger.info(f"Populated preview image for workbook (ID: {workbook_item.id})") def _get_wb_preview_image(self, workbook_item: WorkbookItem) -> bytes: - url = "{0}/{1}/previewImage".format(self.baseurl, workbook_item.id) + url = f"{self.baseurl}/{workbook_item.id}/previewImage" server_response = self.get_request(url) preview_image = server_response.content return preview_image @api(version="2.0") def populate_permissions(self, item: WorkbookItem) -> None: + """ + Populates the permissions for the specified workbook item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_workbook_permissions + + Parameters + ---------- + item : WorkbookItem + The workbook item to populate permissions for. + + Returns + ------- + None + """ self._permissions.populate(item) @api(version="2.0") - def update_permissions(self, resource, rules): + def update_permissions(self, resource: WorkbookItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: + """ + Updates the permissions for the specified workbook item. The method + replaces the existing permissions with the new permissions. Any missing + permissions are removed. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_permissions_for_content + + Parameters + ---------- + resource : WorkbookItem + The workbook item to update permissions for. + + rules : list[PermissionsRule] + A list of permissions rules to apply to the workbook item. + + Returns + ------- + list[PermissionsRule] + The updated permissions rules. + """ return self._permissions.update(resource, rules) @api(version="2.0") - def delete_permission(self, item, capability_item): + def delete_permission(self, item: WorkbookItem, capability_item: PermissionsRule) -> None: + """ + Deletes a single permission rule from the specified workbook item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_workbook_permission + + Parameters + ---------- + item : WorkbookItem + The workbook item to delete the permission from. + + capability_item : PermissionsRule + The permission rule to delete. + + Returns + ------- + None + """ return self._permissions.delete(item, capability_item) @api(version="2.0") @@ -319,10 +680,87 @@ def publish( skip_connection_check: bool = False, parameters=None, ): + """ + Publish a workbook to the specified site. + + Note: The REST API cannot automatically include extracts or other + resources that the workbook uses. Therefore, a .twb file that uses data + from an Excel or csv file on a local computer cannot be published, + unless you package the data and workbook in a .twbx file, or publish the + data source separately. + + For workbooks that are larger than 64 MB, the publish method + automatically takes care of chunking the file in parts for uploading. + Using this method is considerably more convenient than calling the + publish REST APIs directly. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#publish_workbook + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook_item specifies the workbook you are publishing. When + you are adding a workbook, you need to first create a new instance + of a workbook_item that includes a project_id of an existing + project. The name of the workbook will be the name of the file, + unless you also specify a name for the new workbook when you create + the instance. + + file : Path or File object + The file path or file object of the workbook to publish. When + providing a file object, you must also specifiy the name of the + workbook in your instance of the workbook_itemworkbook_item , as + the name cannot be derived from the file name. + + mode : str + Specifies whether you are publishing a new workbook (CreateNew) or + overwriting an existing workbook (Overwrite). You cannot appending + workbooks. You can also use the publish mode attributes, for + example: TSC.Server.PublishMode.Overwrite. + + connections : list[ConnectionItem] | None + List of ConnectionItems objects for the connections created within + the workbook. + + as_job : bool, default False + Set to True to run the upload as a job (asynchronous upload). If set + to True a job will start to perform the publishing process and a Job + object is returned. Defaults to False. + + skip_connection_check : bool, default False + Set to True to skip connection check at time of upload. Publishing + will succeed but unchecked connection issues may result in a + non-functioning workbook. Defaults to False. + + Raises + ------ + OSError + If the file path does not lead to an existing file. + + ServerResponseError + If the server response is not successful. + + TypeError + If the file is not a file path or file object. + + ValueError + If the file extension is not supported + + ValueError + If the mode is invalid. + + ValueError + Workbooks cannot be appended. + + Returns + ------- + WorkbookItem | JobItem + The workbook item or job item that was published. + """ if isinstance(file, (str, os.PathLike)): if not os.path.isfile(file): error = "File path does not lead to an existing file." - raise IOError(error) + raise OSError(error) filename = os.path.basename(file) file_extension = os.path.splitext(filename)[1][1:] @@ -346,12 +784,12 @@ def publish( elif file_type == "xml": file_extension = "twb" else: - error = "Unsupported file type {}!".format(file_type) + error = f"Unsupported file type {file_type}!" raise ValueError(error) # Generate filename for file object. # This is needed when publishing the workbook in a single request - filename = "{}.{}".format(workbook_item.name, file_extension) + filename = f"{workbook_item.name}.{file_extension}" file_size = get_file_object_size(file) else: @@ -362,30 +800,30 @@ def publish( raise ValueError(error) # Construct the url with the defined mode - url = "{0}?workbookType={1}".format(self.baseurl, file_extension) + url = f"{self.baseurl}?workbookType={file_extension}" if mode == self.parent_srv.PublishMode.Overwrite: - url += "&{0}=true".format(mode.lower()) + url += f"&{mode.lower()}=true" elif mode == self.parent_srv.PublishMode.Append: error = "Workbooks cannot be appended." raise ValueError(error) if as_job: - url += "&{0}=true".format("asJob") + url += "&{}=true".format("asJob") if skip_connection_check: - url += "&{0}=true".format("skipConnectionCheck") + url += "&{}=true".format("skipConnectionCheck") # Determine if chunking is required (64MB is the limit for single upload method) if file_size >= FILESIZE_LIMIT: - logger.info("Publishing {0} to server with chunking method (workbook over 64MB)".format(workbook_item.name)) + logger.info(f"Publishing {workbook_item.name} to server with chunking method (workbook over 64MB)") upload_session_id = self.parent_srv.fileuploads.upload(file) - url = "{0}&uploadSessionId={1}".format(url, upload_session_id) + url = f"{url}&uploadSessionId={upload_session_id}" xml_request, content_type = RequestFactory.Workbook.publish_req_chunked( workbook_item, connections=connections, ) else: - logger.info("Publishing {0} to server".format(filename)) + logger.info(f"Publishing {filename} to server") if isinstance(file, (str, Path)): with open(file, "rb") as f: @@ -403,7 +841,7 @@ def publish( file_contents, connections=connections, ) - logger.debug("Request xml: {0} ".format(redact_xml(xml_request[:1000]))) + logger.debug(f"Request xml: {redact_xml(xml_request[:1000])} ") # Send the publishing request to server try: @@ -415,16 +853,38 @@ def publish( if as_job: new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Published {0} (JOB_ID: {1}".format(workbook_item.name, new_job.id)) + logger.info(f"Published {workbook_item.name} (JOB_ID: {new_job.id}") return new_job else: new_workbook = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Published {0} (ID: {1})".format(workbook_item.name, new_workbook.id)) + logger.info(f"Published {workbook_item.name} (ID: {new_workbook.id})") return new_workbook # Populate workbook item's revisions @api(version="2.3") def populate_revisions(self, workbook_item: WorkbookItem) -> None: + """ + Populates (or gets) a list of revisions for a workbook. + + You must first call this method to populate revisions before you can + iterate through the revisions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#get_workbook_revisions + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate revisions for. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -433,12 +893,12 @@ def revisions_fetcher(): return self._get_workbook_revisions(workbook_item) workbook_item._set_revisions(revisions_fetcher) - logger.info("Populated revisions for workbook (ID: {0})".format(workbook_item.id)) + logger.info(f"Populated revisions for workbook (ID: {workbook_item.id})") def _get_workbook_revisions( self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None - ) -> List[RevisionItem]: - url = "{0}/{1}/revisions".format(self.baseurl, workbook_item.id) + ) -> list[RevisionItem]: + url = f"{self.baseurl}/{workbook_item.id}/revisions" server_response = self.get_request(url, req_options) revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, workbook_item) return revisions @@ -452,13 +912,47 @@ def download_revision( filepath: Optional[PathOrFileW] = None, include_extract: bool = True, ) -> PathOrFileW: + """ + Downloads a workbook revision to the specified directory (optional). + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#download_workbook_revision + + Parameters + ---------- + workbook_id : str + The workbook ID. + + revision_number : str | None + The revision number of the workbook. If None, the latest revision is + downloaded. + + filepath : Path or File object, optional + Downloads the file to the location you specify. If no location is + specified, the file is downloaded to the current working directory. + The default is Filepath=None. + + include_extract : bool, default True + Set to False to exclude the extract from the download. The default + is True. + + Returns + ------- + Path or File object + The path to the downloaded workbook or the file object. + + Raises + ------ + ValueError + If the workbook ID is not defined. + """ + if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) if revision_number is None: - url = "{0}/{1}/content".format(self.baseurl, workbook_id) + url = f"{self.baseurl}/{workbook_id}/content" else: - url = "{0}/{1}/revisions/{2}/content".format(self.baseurl, workbook_id, revision_number) + url = f"{self.baseurl}/{workbook_id}/revisions/{revision_number}/content" if not include_extract: url += "?includeExtract=False" @@ -480,37 +974,129 @@ def download_revision( f.write(chunk) return_path = os.path.abspath(download_path) - logger.info( - "Downloaded workbook revision {0} to {1} (ID: {2})".format(revision_number, return_path, workbook_id) - ) + logger.info(f"Downloaded workbook revision {revision_number} to {return_path} (ID: {workbook_id})") return return_path @api(version="2.3") def delete_revision(self, workbook_id: str, revision_number: str) -> None: + """ + Deletes a specific revision from a workbook on Tableau Server. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_revisions.htm#remove_workbook_revision + + Parameters + ---------- + workbook_id : str + The workbook ID. + + revision_number : str + The revision number of the workbook to delete. + + Returns + ------- + None + + Raises + ------ + ValueError + If the workbook ID or revision number is not defined. + """ if workbook_id is None or revision_number is None: raise ValueError url = "/".join([self.baseurl, workbook_id, "revisions", revision_number]) self.delete_request(url) - logger.info("Deleted single workbook revision (ID: {0}) (Revision: {1})".format(workbook_id, revision_number)) + logger.info(f"Deleted single workbook revision (ID: {workbook_id}) (Revision: {revision_number})") # a convenience method @api(version="2.8") def schedule_extract_refresh( self, schedule_id: str, item: WorkbookItem - ) -> List["AddResponse"]: # actually should return a task + ) -> list["AddResponse"]: # actually should return a task + """ + Adds a workbook to a schedule for extract refresh. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#add_workbook_to_schedule + + Parameters + ---------- + schedule_id : str + The schedule ID. + + item : WorkbookItem + The workbook item to add to the schedule. + + Returns + ------- + list[AddResponse] + The response from the server. + """ return self.parent_srv.schedules.add_to_schedule(schedule_id, workbook=item) @api(version="1.0") - def add_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + def add_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> set[str]: + """ + Adds tags to a workbook. One or more tags may be added at a time. If a + tag already exists on the workbook, it will not be duplicated. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#add_tags_to_workbook + + Parameters + ---------- + item : WorkbookItem | str + The workbook item or workbook ID to add tags to. + + tags : Iterable[str] | str + The tag or tags to add to the workbook. Tags can be a single tag or + a list of tags. + + Returns + ------- + set[str] + The set of tags added to the workbook. + """ return super().add_tags(item, tags) @api(version="1.0") def delete_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> None: + """ + Deletes tags from a workbook. One or more tags may be deleted at a time. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#delete_tag_from_workbook + + Parameters + ---------- + item : WorkbookItem | str + The workbook item or workbook ID to delete tags from. + + tags : Iterable[str] | str + The tag or tags to delete from the workbook. Tags can be a single + tag or a list of tags. + + Returns + ------- + None + """ return super().delete_tags(item, tags) @api(version="1.0") def update_tags(self, item: WorkbookItem) -> None: + """ + Updates the tags on a workbook. This method is used to update the tags + on the server to match the tags on the workbook item. This method is a + convenience method that calls add_tags and delete_tags to update the + tags on the server. + + Parameters + ---------- + item : WorkbookItem + The workbook item to update the tags for. The tags on the workbook + item will be used to update the tags on the server. + + Returns + ------- + None + """ return super().update_tags(item) def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[WorkbookItem]: diff --git a/tableauserverclient/server/filter.py b/tableauserverclient/server/filter.py index b936ceb92..fd90e281f 100644 --- a/tableauserverclient/server/filter.py +++ b/tableauserverclient/server/filter.py @@ -1,7 +1,7 @@ from .request_options import RequestOptions -class Filter(object): +class Filter: def __init__(self, field, operator, value): self.field = field self.operator = operator @@ -16,7 +16,7 @@ def __str__(self): # 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) + return f"{self.field}:{self.operator}:{value_string}" @property def value(self): diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index ca9d83872..e6d261b61 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -1,6 +1,7 @@ import copy from functools import partial -from typing import Iterable, Iterator, List, Optional, Protocol, Tuple, TypeVar, Union, runtime_checkable +from typing import Optional, Protocol, TypeVar, Union, runtime_checkable +from collections.abc import Iterable, Iterator from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.request_options import RequestOptions @@ -11,14 +12,12 @@ @runtime_checkable class Endpoint(Protocol[T]): - def get(self, req_options: Optional[RequestOptions]) -> Tuple[List[T], PaginationItem]: - ... + def get(self, req_options: Optional[RequestOptions]) -> tuple[list[T], PaginationItem]: ... @runtime_checkable class CallableEndpoint(Protocol[T]): - def __call__(self, __req_options: Optional[RequestOptions], **kwargs) -> Tuple[List[T], PaginationItem]: - ... + def __call__(self, __req_options: Optional[RequestOptions], **kwargs) -> tuple[list[T], PaginationItem]: ... class Pager(Iterable[T]): @@ -27,7 +26,7 @@ class Pager(Iterable[T]): Supports all `RequestOptions` including starting on any page. Also used by models to load sub-models (users in a group, views in a workbook, etc) by passing a different endpoint. - Will loop over anything that returns (List[ModelItem], PaginationItem). + Will loop over anything that returns (list[ModelItem], PaginationItem). """ def __init__( diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index bbca612e9..801ad4a13 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -1,8 +1,10 @@ -from collections.abc import Sized +from collections.abc import Iterable, Iterator, Sized from itertools import count -from typing import Iterable, Iterator, List, Optional, Protocol, Tuple, TYPE_CHECKING, TypeVar, overload +from typing import Optional, Protocol, TYPE_CHECKING, TypeVar, overload +import sys from tableauserverclient.config import config from tableauserverclient.models.pagination_item import PaginationItem +from tableauserverclient.server.endpoint.exceptions import ServerResponseError from tableauserverclient.server.filter import Filter from tableauserverclient.server.request_options import RequestOptions from tableauserverclient.server.sort import Sort @@ -34,10 +36,36 @@ def to_camel_case(word: str) -> str: class QuerySet(Iterable[T], Sized): + """ + QuerySet is a class that allows easy filtering, sorting, and iterating over + many endpoints in TableauServerClient. It is designed to be used in a similar + way to Django QuerySets, but with a more limited feature set. + + QuerySet is an iterable, and can be used in for loops, list comprehensions, + and other places where iterables are expected. + + QuerySet is also Sized, and can be used in places where the length of the + QuerySet is needed. The length of the QuerySet is the total number of items + available in the QuerySet, not just the number of items that have been + fetched. If the endpoint does not return a total count of items, the length + of the QuerySet will be sys.maxsize. If there is no total count, the + QuerySet will continue to fetch items until there are no more items to + fetch. + + QuerySet is not re-entrant. It is not designed to be used in multiple places + at the same time. If you need to use a QuerySet in multiple places, you + should create a new QuerySet for each place you need to use it, convert it + to a list, or create a deep copy of the QuerySet. + + QuerySets are also indexable, and can be sliced. If you try to access an + index that has not been fetched, the QuerySet will fetch the page that + contains the item you are looking for. + """ + def __init__(self, model: "QuerysetEndpoint[T]", page_size: Optional[int] = None) -> None: self.model = model self.request_options = RequestOptions(pagesize=page_size or config.PAGE_SIZE) - self._result_cache: List[T] = [] + self._result_cache: list[T] = [] self._pagination_item = PaginationItem() def __iter__(self: Self) -> Iterator[T]: @@ -49,19 +77,30 @@ def __iter__(self: Self) -> Iterator[T]: for page in count(1): self.request_options.pagenumber = page self._result_cache = [] - self._fetch_all() + self._pagination_item._page_number = None + try: + self._fetch_all() + except ServerResponseError as e: + if e.code == "400006": + # If the endpoint does not support pagination, it will end + # up overrunning the total number of pages. Catch the + # error and break out of the loop. + raise StopIteration + if len(self._result_cache) == 0: + return yield from self._result_cache - # Set result_cache to empty so the fetch will populate - if (page * self.page_size) >= len(self): + # If the length of the QuerySet is unknown, continue fetching until + # the result cache is empty. + if (size := len(self)) == 0: + continue + if (page * self.page_size) >= size: return @overload - def __getitem__(self: Self, k: Slice) -> List[T]: - ... + def __getitem__(self: Self, k: Slice) -> list[T]: ... @overload - def __getitem__(self: Self, k: int) -> T: - ... + def __getitem__(self: Self, k: int) -> T: ... def __getitem__(self, k): page = self.page_number @@ -103,6 +142,7 @@ def __getitem__(self, k): elif k in range(self.total_available): # Otherwise, check if k is even sensible to return self._result_cache = [] + self._pagination_item._page_number = None # 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] @@ -114,11 +154,16 @@ 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) + if not self._result_cache and self._pagination_item._page_number is None: + response = self.model.get(self.request_options) + if isinstance(response, tuple): + self._result_cache, self._pagination_item = response + else: + self._result_cache = response + self._pagination_item = PaginationItem() def __len__(self: Self) -> int: - return self.total_available + return sys.maxsize if self.total_available is None else self.total_available @property def total_available(self: Self) -> int: @@ -128,12 +173,16 @@ def total_available(self: Self) -> int: @property def page_number(self: Self) -> int: self._fetch_all() - return self._pagination_item.page_number + # If the PaginationItem is not returned from the endpoint, use the + # pagenumber from the RequestOptions. + return self._pagination_item.page_number or self.request_options.pagenumber @property def page_size(self: Self) -> int: self._fetch_all() - return self._pagination_item.page_size + # If the PaginationItem is not returned from the endpoint, use the + # pagesize from the RequestOptions. + return self._pagination_item.page_size or self.request_options.pagesize def filter(self: Self, *invalid, page_size: Optional[int] = None, **kwargs) -> Self: if invalid: @@ -160,22 +209,22 @@ def paginate(self: Self, **kwargs) -> Self: return self @staticmethod - def _parse_shorthand_filter(key: str) -> Tuple[str, str]: + def _parse_shorthand_filter(key: str) -> tuple[str, str]: tokens = key.split("__", 1) if len(tokens) == 1: operator = RequestOptions.Operator.Equals else: operator = tokens[1] if operator not in RequestOptions.Operator.__dict__.values(): - raise ValueError("Operator `{}` is not valid.".format(operator)) + raise ValueError(f"Operator `{operator}` is not valid.") field = to_camel_case(tokens[0]) if field not in RequestOptions.Field.__dict__.values(): - raise ValueError("Field name `{}` is not valid.".format(field)) + raise ValueError(f"Field name `{field}` is not valid.") return (field, operator) @staticmethod - def _parse_shorthand_sort(key: str) -> Tuple[str, str]: + def _parse_shorthand_sort(key: str) -> tuple[str, str]: direction = RequestOptions.Direction.Asc if key.startswith("-"): direction = RequestOptions.Direction.Desc diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 96fa14680..f7bd139d7 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1,5 +1,6 @@ import xml.etree.ElementTree as ET -from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, TypeVar, TYPE_CHECKING, Union +from typing import Any, Callable, Optional, TypeVar, TYPE_CHECKING, Union +from collections.abc import Iterable from typing_extensions import ParamSpec @@ -15,7 +16,7 @@ # this file could be largely replaced if we were willing to import the huge file from generateDS -def _add_multipart(parts: Dict) -> Tuple[Any, str]: +def _add_multipart(parts: dict) -> tuple[Any, str]: mime_multipart_parts = list() for name, (filename, data, content_type) in parts.items(): multipart_part = RequestField(name=name, data=data, filename=filename) @@ -80,7 +81,7 @@ def _add_credentials_element(parent_element, connection_credentials): credentials_element.attrib["oAuth"] = "true" -class AuthRequest(object): +class AuthRequest: def signin_req(self, auth_item): xml_request = ET.Element("tsRequest") @@ -104,7 +105,7 @@ def switch_req(self, site_content_url): return ET.tostring(xml_request) -class ColumnRequest(object): +class ColumnRequest: def update_req(self, column_item): xml_request = ET.Element("tsRequest") column_element = ET.SubElement(xml_request, "column") @@ -115,7 +116,7 @@ def update_req(self, column_item): return ET.tostring(xml_request) -class DataAlertRequest(object): +class DataAlertRequest: def add_user_to_alert(self, alert_item: "DataAlertItem", user_id: str) -> bytes: xml_request = ET.Element("tsRequest") user_element = ET.SubElement(xml_request, "user") @@ -140,7 +141,7 @@ def update_req(self, alert_item: "DataAlertItem") -> bytes: return ET.tostring(xml_request) -class DatabaseRequest(object): +class DatabaseRequest: def update_req(self, database_item): xml_request = ET.Element("tsRequest") database_element = ET.SubElement(xml_request, "database") @@ -159,7 +160,7 @@ def update_req(self, database_item): return ET.tostring(xml_request) -class DatasourceRequest(object): +class DatasourceRequest: def _generate_xml(self, datasource_item: DatasourceItem, connection_credentials=None, connections=None): xml_request = ET.Element("tsRequest") datasource_element = ET.SubElement(xml_request, "datasource") @@ -244,7 +245,7 @@ def publish_req_chunked(self, datasource_item, connection_credentials=None, conn return _add_multipart(parts) -class DQWRequest(object): +class DQWRequest: def add_req(self, dqw_item): xml_request = ET.Element("tsRequest") dqw_element = ET.SubElement(xml_request, "dataQualityWarning") @@ -274,7 +275,7 @@ def update_req(self, dqw_item): return ET.tostring(xml_request) -class FavoriteRequest(object): +class FavoriteRequest: def add_request(self, id_: Optional[str], target_type: str, label: Optional[str]) -> bytes: """ @@ -329,7 +330,7 @@ def add_workbook_req(self, id_: Optional[str], name: Optional[str]) -> bytes: return self.add_request(id_, Resource.Workbook, name) -class FileuploadRequest(object): +class FileuploadRequest: def chunk_req(self, chunk): parts = { "request_payload": ("", "", "text/xml"), @@ -338,8 +339,8 @@ def chunk_req(self, chunk): return _add_multipart(parts) -class FlowRequest(object): - def _generate_xml(self, flow_item: "FlowItem", connections: Optional[List["ConnectionItem"]] = None) -> bytes: +class FlowRequest: + def _generate_xml(self, flow_item: "FlowItem", connections: Optional[list["ConnectionItem"]] = None) -> bytes: xml_request = ET.Element("tsRequest") flow_element = ET.SubElement(xml_request, "flow") if flow_item.name is not None: @@ -370,8 +371,8 @@ def publish_req( flow_item: "FlowItem", filename: str, file_contents: bytes, - connections: Optional[List["ConnectionItem"]] = None, - ) -> Tuple[Any, str]: + connections: Optional[list["ConnectionItem"]] = None, + ) -> tuple[Any, str]: xml_request = self._generate_xml(flow_item, connections) parts = { @@ -380,14 +381,14 @@ def publish_req( } return _add_multipart(parts) - def publish_req_chunked(self, flow_item, connections=None) -> Tuple[Any, str]: + def publish_req_chunked(self, flow_item, connections=None) -> tuple[Any, str]: xml_request = self._generate_xml(flow_item, connections) parts = {"request_payload": ("", xml_request, "text/xml")} return _add_multipart(parts) -class GroupRequest(object): +class GroupRequest: def add_user_req(self, user_id: str) -> bytes: xml_request = ET.Element("tsRequest") user_element = ET.SubElement(xml_request, "user") @@ -477,7 +478,7 @@ def update_req( return ET.tostring(xml_request) -class PermissionRequest(object): +class PermissionRequest: def add_req(self, rules: Iterable[PermissionsRule]) -> bytes: xml_request = ET.Element("tsRequest") permissions_element = ET.SubElement(xml_request, "permissions") @@ -499,7 +500,7 @@ def _add_all_capabilities(self, capabilities_element, capabilities_map): capability_element.attrib["mode"] = mode -class ProjectRequest(object): +class ProjectRequest: def update_req(self, project_item: "ProjectItem") -> bytes: xml_request = ET.Element("tsRequest") project_element = ET.SubElement(xml_request, "project") @@ -530,7 +531,7 @@ def create_req(self, project_item: "ProjectItem") -> bytes: return ET.tostring(xml_request) -class ScheduleRequest(object): +class ScheduleRequest: def create_req(self, schedule_item): xml_request = ET.Element("tsRequest") schedule_element = ET.SubElement(xml_request, "schedule") @@ -609,7 +610,7 @@ def add_flow_req(self, id_: Optional[str], task_type: str = TaskItem.Type.RunFlo return self._add_to_req(id_, "flow", task_type) -class SiteRequest(object): +class SiteRequest: def update_req(self, site_item: "SiteItem", parent_srv: Optional["Server"] = None): xml_request = ET.Element("tsRequest") site_element = ET.SubElement(xml_request, "site") @@ -848,7 +849,7 @@ def set_versioned_flow_attributes(self, flows_all, flows_edit, flows_schedule, p warnings.warn("In version 3.10 and earlier there is only one option: FlowsEnabled") -class TableRequest(object): +class TableRequest: def update_req(self, table_item): xml_request = ET.Element("tsRequest") table_element = ET.SubElement(xml_request, "table") @@ -871,7 +872,7 @@ def update_req(self, table_item): content_types = Iterable[Union["ColumnItem", "DatabaseItem", "DatasourceItem", "FlowItem", "TableItem", "WorkbookItem"]] -class TagRequest(object): +class TagRequest: def add_req(self, tag_set): xml_request = ET.Element("tsRequest") tags_element = ET.SubElement(xml_request, "tags") @@ -881,7 +882,7 @@ def add_req(self, tag_set): return ET.tostring(xml_request) @_tsrequest_wrapped - def batch_create(self, element: ET.Element, tags: Set[str], content: content_types) -> bytes: + def batch_create(self, element: ET.Element, tags: set[str], content: content_types) -> bytes: tag_batch = ET.SubElement(element, "tagBatch") tags_element = ET.SubElement(tag_batch, "tags") for tag in tags: @@ -897,7 +898,7 @@ def batch_create(self, element: ET.Element, tags: Set[str], content: content_typ return ET.tostring(element) -class UserRequest(object): +class UserRequest: def update_req(self, user_item: UserItem, password: Optional[str]) -> bytes: xml_request = ET.Element("tsRequest") user_element = ET.SubElement(xml_request, "user") @@ -931,7 +932,7 @@ def add_req(self, user_item: UserItem) -> bytes: return ET.tostring(xml_request) -class WorkbookRequest(object): +class WorkbookRequest: def _generate_xml( self, workbook_item, @@ -995,9 +996,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.") @@ -1075,7 +1076,7 @@ def embedded_extract_req( datasource_element.attrib["id"] = id_ -class Connection(object): +class Connection: @_tsrequest_wrapped def update_req(self, xml_request: ET.Element, connection_item: "ConnectionItem") -> None: connection_element = ET.SubElement(xml_request, "connection") @@ -1098,7 +1099,7 @@ def update_req(self, xml_request: ET.Element, connection_item: "ConnectionItem") connection_element.attrib["queryTaggingEnabled"] = str(connection_item.query_tagging).lower() -class TaskRequest(object): +class TaskRequest: @_tsrequest_wrapped def run_req(self, xml_request: ET.Element, task_item: Any) -> None: # Send an empty tsRequest @@ -1137,7 +1138,7 @@ def create_extract_req(self, xml_request: ET.Element, extract_item: "TaskItem") return ET.tostring(xml_request) -class FlowTaskRequest(object): +class FlowTaskRequest: @_tsrequest_wrapped def create_flow_task_req(self, xml_request: ET.Element, flow_item: "TaskItem") -> bytes: flow_element = ET.SubElement(xml_request, "runFlow") @@ -1171,7 +1172,7 @@ def create_flow_task_req(self, xml_request: ET.Element, flow_item: "TaskItem") - return ET.tostring(xml_request) -class SubscriptionRequest(object): +class SubscriptionRequest: @_tsrequest_wrapped def create_req(self, xml_request: ET.Element, subscription_item: "SubscriptionItem") -> bytes: subscription_element = ET.SubElement(xml_request, "subscription") @@ -1235,13 +1236,13 @@ def update_req(self, xml_request: ET.Element, subscription_item: "SubscriptionIt return ET.tostring(xml_request) -class EmptyRequest(object): +class EmptyRequest: @_tsrequest_wrapped def empty_req(self, xml_request: ET.Element) -> None: pass -class WebhookRequest(object): +class WebhookRequest: @_tsrequest_wrapped def create_req(self, xml_request: ET.Element, webhook_item: "WebhookItem") -> bytes: webhook = ET.SubElement(xml_request, "webhook") @@ -1287,7 +1288,7 @@ def update_req(self, xml_request: ET.Element, metric_item: MetricItem) -> bytes: return ET.tostring(xml_request) -class CustomViewRequest(object): +class CustomViewRequest: @_tsrequest_wrapped def update_req(self, xml_request: ET.Element, custom_view_item: CustomViewItem): updating_element = ET.SubElement(xml_request, "customView") @@ -1415,7 +1416,7 @@ def publish(self, xml_request: ET.Element, virtual_connection: VirtualConnection return ET.tostring(xml_request) -class RequestFactory(object): +class RequestFactory: Auth = AuthRequest() Connection = Connection() Column = ColumnRequest() diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index ddb45834d..d79ac7f73 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,4 +1,5 @@ import sys +from typing import Optional from typing_extensions import Self @@ -9,12 +10,12 @@ from tableauserverclient.helpers.logging import logger -class RequestOptionsBase(object): +class RequestOptionsBase: # This method is used if server api version is below 3.7 (2020.1) def apply_query_params(self, url): try: params = self.get_query_params() - params_list = ["{}={}".format(k, v) for (k, v) in params.items()] + params_list = [f"{k}={v}" for (k, v) in params.items()] logger.debug("Applying options to request: <%s(%s)>", self.__class__.__name__, ",".join(params_list)) @@ -22,15 +23,52 @@ def apply_query_params(self, url): url, existing_params = url.split("?") params_list.append(existing_params) - return "{0}?{1}".format(url, "&".join(params_list)) + return "{}?{}".format(url, "&".join(params_list)) except NotImplementedError: raise - def get_query_params(self): - raise NotImplementedError() + +# If it wasn't a breaking change, I'd rename it to QueryOptions +""" +This class manages options can be used when querying content on the server +""" class RequestOptions(RequestOptionsBase): + def __init__(self, pagenumber=1, pagesize=None): + self.pagenumber = pagenumber + self.pagesize = pagesize or config.PAGE_SIZE + self.sort = set() + self.filter = set() + # This is private until we expand all of our parsers to handle the extra fields + self._all_fields = False + + def get_query_params(self) -> dict: + params = {} + if self.sort and len(self.sort) > 0: + sort_options = (str(sort_item) for sort_item in self.sort) + ordered_sort_options = sorted(sort_options) + params["sort"] = ",".join(ordered_sort_options) + if len(self.filter) > 0: + filter_options = (str(filter_item) for filter_item in self.filter) + ordered_filter_options = sorted(filter_options) + params["filter"] = ",".join(ordered_filter_options) + if self._all_fields: + params["fields"] = "_all_" + if self.pagenumber: + params["pageNumber"] = self.pagenumber + if self.pagesize: + params["pageSize"] = self.pagesize + return params + + def page_size(self, page_size): + self.pagesize = page_size + return self + + def page_number(self, page_number): + self.pagenumber = page_number + return self + class Operator: Equals = "eq" GreaterThan = "gt" @@ -41,6 +79,7 @@ class Operator: Has = "has" CaseInsensitiveEquals = "cieq" + # These are fields in the REST API class Field: Args = "args" AuthenticationType = "authenticationType" @@ -117,60 +156,53 @@ class Direction: Desc = "desc" Asc = "asc" - def __init__(self, pagenumber=1, pagesize=None): - self.pagenumber = pagenumber - self.pagesize = pagesize or config.PAGE_SIZE - self.sort = set() - self.filter = set() - - # This is private until we expand all of our parsers to handle the extra fields - self._all_fields = False - def page_size(self, page_size): - self.pagesize = page_size - return self - - def page_number(self, page_number): - self.pagenumber = page_number - return self +""" +These options can be used by methods that are fetching data exported from a specific content item +""" - def get_query_params(self): - params = {} - if self.pagenumber: - params["pageNumber"] = self.pagenumber - if self.pagesize: - params["pageSize"] = self.pagesize - if len(self.sort) > 0: - sort_options = (str(sort_item) for sort_item in self.sort) - ordered_sort_options = sorted(sort_options) - params["sort"] = ",".join(ordered_sort_options) - if len(self.filter) > 0: - filter_options = (str(filter_item) for filter_item in self.filter) - ordered_filter_options = sorted(filter_options) - params["filter"] = ",".join(ordered_filter_options) - if self._all_fields: - params["fields"] = "_all_" - return params +class _DataExportOptions(RequestOptionsBase): + def __init__(self, maxage: int = -1): + super().__init__() + self.view_filters: list[tuple[str, str]] = [] + self.view_parameters: list[tuple[str, str]] = [] + self.max_age: Optional[int] = maxage + """ + This setting will affect the contents of the workbook as they are exported. + Valid language values are tableau-supported languages like de, es, en + If no locale is specified, the default locale for that language will be used + """ + self.language: Optional[str] = None -class _FilterOptionsBase(RequestOptionsBase): - """Provide a basic implementation of adding view filters to the url""" + @property + def max_age(self) -> int: + return self._max_age - def __init__(self): - self.view_filters = [] - self.view_parameters = [] + @max_age.setter + @property_is_int(range=(0, 240), allowed=[-1]) + def max_age(self, value): + self._max_age = value def get_query_params(self): - raise NotImplementedError() + params = {} + if self.max_age != -1: + params["maxAge"] = self.max_age + if self.language: + params["language"] = self.language + + self._append_view_filters(params) + return params 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.""" + """Apply a filter based on a column within the view. + Note that when filtering on a boolean type field, the only valid values are 'true' and 'false'""" self.view_filters.append((name, value)) return self def parameter(self, name: str, value: str) -> Self: - """Apply a filter based on a parameter within the workbook.""" + """Apply a filter based on a parameter within the workbook. + Note that when filtering on a boolean type field, the only valid values are 'true' and 'false'""" self.view_parameters.append((name, value)) return self @@ -181,82 +213,73 @@ def _append_view_filters(self, params) -> None: params[name] = value -class CSVRequestOptions(_FilterOptionsBase): - def __init__(self, maxage=-1): - super(CSVRequestOptions, self).__init__() - self.max_age = maxage +class _ImagePDFCommonExportOptions(_DataExportOptions): + def __init__(self, maxage=-1, viz_height=None, viz_width=None): + super().__init__(maxage=maxage) + self.viz_height = viz_height + self.viz_width = viz_width @property - def max_age(self): - return self._max_age - - @max_age.setter - @property_is_int(range=(0, 240), allowed=[-1]) - def max_age(self, value): - self._max_age = value + def viz_height(self): + return self._viz_height - def get_query_params(self): - params = {} - if self.max_age != -1: - params["maxAge"] = self.max_age + @viz_height.setter + @property_is_int(range=(0, sys.maxsize), allowed=(None,)) + def viz_height(self, value): + self._viz_height = value - self._append_view_filters(params) - return params + @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 -class ExcelRequestOptions(_FilterOptionsBase): - def __init__(self, maxage: int = -1) -> None: - super().__init__() - self.max_age = maxage + def get_query_params(self) -> dict: + params = super().get_query_params() - @property - def max_age(self) -> int: - return 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") - @max_age.setter - @property_is_int(range=(0, 240), allowed=[-1]) - def max_age(self, value: int) -> None: - self._max_age = value + if self.viz_height is not None: + params["vizHeight"] = self.viz_height - def get_query_params(self): - params = {} - if self.max_age != -1: - params["maxAge"] = self.max_age + if self.viz_width is not None: + params["vizWidth"] = self.viz_width - self._append_view_filters(params) return params -class ImageRequestOptions(_FilterOptionsBase): +class CSVRequestOptions(_DataExportOptions): + extension = "csv" + + +class ExcelRequestOptions(_DataExportOptions): + extension = "xlsx" + + +class ImageRequestOptions(_ImagePDFCommonExportOptions): + extension = "png" + # if 'high' isn't specified, the REST API endpoint returns an image with standard resolution class Resolution: High = "high" - def __init__(self, imageresolution=None, maxage=-1): - super(ImageRequestOptions, self).__init__() + def __init__(self, imageresolution=None, maxage=-1, viz_height=None, viz_width=None): + super().__init__(maxage=maxage, viz_height=viz_height, viz_width=viz_width) self.image_resolution = imageresolution - self.max_age = maxage - - @property - def max_age(self): - return self._max_age - - @max_age.setter - @property_is_int(range=(0, 240), allowed=[-1]) - def max_age(self, value): - self._max_age = value def get_query_params(self): - params = {} + params = super().get_query_params() if self.image_resolution: params["resolution"] = self.image_resolution - if self.max_age != -1: - params["maxAge"] = self.max_age - self._append_view_filters(params) return params -class PDFRequestOptions(_FilterOptionsBase): +class PDFRequestOptions(_ImagePDFCommonExportOptions): class PageType: A3 = "a3" A4 = "a4" @@ -278,61 +301,16 @@ class Orientation: Landscape = "landscape" def __init__(self, page_type=None, orientation=None, maxage=-1, viz_height=None, viz_width=None): - super(PDFRequestOptions, self).__init__() + super().__init__(maxage=maxage, viz_height=viz_height, viz_width=viz_width) 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): - return self._max_age - - @max_age.setter - @property_is_int(range=(0, 240), allowed=[-1]) - 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 = {} + def get_query_params(self) -> dict: + params = super().get_query_params() if self.page_type: params["type"] = self.page_type if self.orientation: params["orientation"] = self.orientation - 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") - - 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/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index e563a7138..4eeefcaf9 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -58,8 +58,64 @@ default_server_version = "2.4" # first version that dropped the legacy auth endpoint -class Server(object): +class Server: + """ + In the Tableau REST API, the server (https://MY-SERVER/) is the base or core + of the URI that makes up the various endpoints or methods for accessing + resources on the server (views, workbooks, sites, users, data sources, etc.) + The TSC library provides a Server class that represents the server. You + create a server instance to sign in to the server and to call the various + methods for accessing resources. + + The Server class contains the attributes that represent the server on + Tableau Server. After you create an instance of the Server class, you can + sign in to the server and call methods to access all of the resources on the + server. + + Parameters + ---------- + server_address : str + Specifies the address of the Tableau Server or Tableau Cloud (for + example, https://MY-SERVER/). + + use_server_version : bool + Specifies the version of the REST API to use (for example, '2.5'). When + you use the TSC library to call methods that access Tableau Server, the + version is passed to the endpoint as part of the URI + (https://MY-SERVER/api/2.5/). Each release of Tableau Server supports + specific versions of the REST API. New versions of the REST API are + released with Tableau Server. By default, the value of version is set to + '2.3', which corresponds to Tableau Server 10.0. You can view or set + this value. You might need to set this to a different value, for + example, if you want to access features that are supported by the server + and a later version of the REST API. For more information, see REST API + Versions. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> # create a instance of server + >>> server = TSC.Server('https://MY-SERVER') + + >>> # sign in, etc. + + >>> # change the REST API version to match the server + >>> server.use_server_version() + + >>> # or change the REST API version to match a specific version + >>> # for example, 2.8 + >>> # server.version = '2.8' + + """ + class PublishMode: + """ + Enumerates the options that specify what happens when you publish a + workbook or data source. The options are Overwrite, Append, or + CreateNew. + """ + Append = "Append" Overwrite = "Overwrite" CreateNew = "CreateNew" @@ -130,7 +186,7 @@ def validate_connection_settings(self): raise ValueError("Server connection settings not valid", req_ex) def __repr__(self): - return "".format(self.baseurl, self.server_info.serverInfo) + return f"" def add_http_options(self, options_dict: dict): try: @@ -142,7 +198,7 @@ def add_http_options(self, options_dict: dict): # expected errors on invalid input: # 'set' object has no attribute 'keys', 'list' object has no attribute 'keys' # TypeError: cannot convert dictionary update sequence element #0 to a sequence (input is a tuple) - raise ValueError("Invalid http options given: {}".format(options_dict)) + raise ValueError(f"Invalid http options given: {options_dict}") def clear_http_options(self): self._http_options = dict() @@ -176,15 +232,15 @@ def _determine_highest_version(self): old_version = self.version version = self.server_info.get().rest_api_version except ServerInfoEndpointNotFoundError as e: - logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) + logger.info(f"Could not get version info from server: {e.__class__}{e}") version = self._get_legacy_version() except EndpointUnavailableError as e: - logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) + logger.info(f"Could not get version info from server: {e.__class__}{e}") version = self._get_legacy_version() except Exception as e: - logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) + logger.info(f"Could not get version info from server: {e.__class__}{e}") version = None - logger.info("versions: {}, {}".format(version, old_version)) + logger.info(f"versions: {version}, {old_version}") return version or old_version def use_server_version(self): @@ -201,12 +257,12 @@ def check_at_least_version(self, target: str): def assert_at_least_version(self, comparison: str, reason: str): if not self.check_at_least_version(comparison): - error = "{} is not available in API version {}. Requires {}".format(reason, self.version, comparison) + error = f"{reason} is not available in API version {self.version}. Requires {comparison}" raise EndpointUnavailableError(error) @property def baseurl(self): - return "{0}/api/{1}".format(self._server_address, str(self.version)) + return f"{self._server_address}/api/{str(self.version)}" @property def namespace(self): diff --git a/tableauserverclient/server/sort.py b/tableauserverclient/server/sort.py index 2d6bc030a..839a8c8db 100644 --- a/tableauserverclient/server/sort.py +++ b/tableauserverclient/server/sort.py @@ -1,7 +1,7 @@ -class Sort(object): +class Sort: def __init__(self, field, direction): self.field = field self.direction = direction def __str__(self): - return "{0}:{1}".format(self.field, self.direction) + return f"{self.field}:{self.direction}" diff --git a/test/_utils.py b/test/_utils.py index 8527aaf8c..b4ee93bc3 100644 --- a/test/_utils.py +++ b/test/_utils.py @@ -1,5 +1,6 @@ import os.path import unittest +from xml.etree import ElementTree as ET from contextlib import contextmanager TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -18,6 +19,19 @@ def read_xml_assets(*args): return map(read_xml_asset, args) +def server_response_error_factory(code: str, summary: str, detail: str) -> str: + root = ET.Element("tsResponse") + error = ET.SubElement(root, "error") + error.attrib["code"] = code + + summary_element = ET.SubElement(error, "summary") + summary_element.text = summary + + detail_element = ET.SubElement(error, "detail") + detail_element.text = detail + return ET.tostring(root, encoding="utf-8").decode("utf-8") + + @contextmanager def mocked_time(): mock_time = 0 diff --git a/test/assets/flow_runs_get.xml b/test/assets/flow_runs_get.xml index bdce4cdfb..489e8ac63 100644 --- a/test/assets/flow_runs_get.xml +++ b/test/assets/flow_runs_get.xml @@ -1,5 +1,4 @@ - - \ No newline at end of file + diff --git a/test/assets/server_info_wrong_site.html b/test/assets/server_info_wrong_site.html new file mode 100644 index 000000000..e92daeb2d --- /dev/null +++ b/test/assets/server_info_wrong_site.html @@ -0,0 +1,56 @@ + + + + + + Example website + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ABCDE
12345
23456
34567
45678
56789
+ + + \ No newline at end of file diff --git a/test/test_auth.py b/test/test_auth.py index eaf13481e..48100ad88 100644 --- a/test/test_auth.py +++ b/test/test_auth.py @@ -63,7 +63,7 @@ def test_sign_in_error(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.TableauAuth("testuser", "wrongpassword") - self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.FailedSignInError, self.server.auth.sign_in, tableau_auth) def test_sign_in_invalid_token(self): with open(SIGN_IN_ERROR_XML, "rb") as f: @@ -71,7 +71,7 @@ def test_sign_in_invalid_token(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.PersonalAccessTokenAuth(token_name="mytoken", personal_access_token="invalid") - self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.FailedSignInError, self.server.auth.sign_in, tableau_auth) def test_sign_in_without_auth(self): with open(SIGN_IN_ERROR_XML, "rb") as f: @@ -79,7 +79,7 @@ def test_sign_in_without_auth(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.TableauAuth("", "") - self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.FailedSignInError, self.server.auth.sign_in, tableau_auth) def test_sign_out(self): with open(SIGN_IN_XML, "rb") as f: diff --git a/test/test_custom_view.py b/test/test_custom_view.py index 80800c86b..6e863a863 100644 --- a/test/test_custom_view.py +++ b/test/test_custom_view.py @@ -18,6 +18,8 @@ GET_XML_ID = os.path.join(TEST_ASSET_DIR, "custom_view_get_id.xml") POPULATE_PREVIEW_IMAGE = os.path.join(TEST_ASSET_DIR, "Sample View Image.png") CUSTOM_VIEW_UPDATE_XML = os.path.join(TEST_ASSET_DIR, "custom_view_update.xml") +CUSTOM_VIEW_POPULATE_PDF = os.path.join(TEST_ASSET_DIR, "populate_pdf.pdf") +CUSTOM_VIEW_POPULATE_CSV = os.path.join(TEST_ASSET_DIR, "populate_csv.csv") CUSTOM_VIEW_DOWNLOAD = TEST_ASSET_DIR / "custom_view_download.json" FILE_UPLOAD_INIT = TEST_ASSET_DIR / "fileupload_initialize.xml" FILE_UPLOAD_APPEND = TEST_ASSET_DIR / "fileupload_append.xml" @@ -246,3 +248,73 @@ def test_large_publish(self): assert isinstance(view, TSC.CustomViewItem) assert view.id is not None assert view.name is not None + + def test_populate_pdf(self) -> None: + self.server.version = "3.23" + self.baseurl = self.server.custom_views.baseurl + with open(CUSTOM_VIEW_POPULATE_PDF, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get( + self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/pdf?type=letter&orientation=portrait&maxAge=5", + content=response, + ) + custom_view = TSC.CustomViewItem() + custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + + size = TSC.PDFRequestOptions.PageType.Letter + orientation = TSC.PDFRequestOptions.Orientation.Portrait + req_option = TSC.PDFRequestOptions(size, orientation, 5) + + self.server.custom_views.populate_pdf(custom_view, req_option) + self.assertEqual(response, custom_view.pdf) + + def test_populate_csv(self) -> None: + self.server.version = "3.23" + self.baseurl = self.server.custom_views.baseurl + with open(CUSTOM_VIEW_POPULATE_CSV, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/data?maxAge=1", content=response) + custom_view = TSC.CustomViewItem() + custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + request_option = TSC.CSVRequestOptions(maxage=1) + self.server.custom_views.populate_csv(custom_view, request_option) + + csv_file = b"".join(custom_view.csv) + self.assertEqual(response, csv_file) + + def test_populate_csv_default_maxage(self) -> None: + self.server.version = "3.23" + self.baseurl = self.server.custom_views.baseurl + with open(CUSTOM_VIEW_POPULATE_CSV, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/data", content=response) + custom_view = TSC.CustomViewItem() + custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + self.server.custom_views.populate_csv(custom_view) + + csv_file = b"".join(custom_view.csv) + self.assertEqual(response, csv_file) + + def test_pdf_height(self) -> None: + self.server.version = "3.23" + self.baseurl = self.server.custom_views.baseurl + with open(CUSTOM_VIEW_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, + ) + custom_view = TSC.CustomViewItem() + custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + + req_option = TSC.PDFRequestOptions( + viz_height=1080, + viz_width=1920, + ) + + self.server.custom_views.populate_pdf(custom_view, req_option) + self.assertEqual(response, custom_view.pdf) diff --git a/test/test_dataalert.py b/test/test_dataalert.py index d9e00a9db..6f6f1683c 100644 --- a/test/test_dataalert.py +++ b/test/test_dataalert.py @@ -108,5 +108,5 @@ def test_delete_user_from_alert(self) -> None: alert_id = "5ea59b45-e497-5673-8809-bfe213236f75" user_id = "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" with requests_mock.mock() as m: - m.delete(self.baseurl + "/{0}/users/{1}".format(alert_id, user_id), status_code=204) + m.delete(self.baseurl + f"/{alert_id}/users/{user_id}", status_code=204) self.server.data_alerts.delete_user_from_alert(alert_id, user_id) diff --git a/test/test_datasource.py b/test/test_datasource.py index 624eb93e1..45d9ba9c9 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -75,7 +75,7 @@ def test_get(self) -> None: self.assertEqual("Sample datasource", all_datasources[1].name) self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", all_datasources[1].project_id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_datasources[1].owner_id) - self.assertEqual(set(["world", "indicators", "sample"]), all_datasources[1].tags) + self.assertEqual({"world", "indicators", "sample"}, all_datasources[1].tags) self.assertEqual("https://page.com", all_datasources[1].webpage_url) self.assertTrue(all_datasources[1].encrypt_extracts) self.assertFalse(all_datasources[1].has_extracts) @@ -110,7 +110,7 @@ def test_get_by_id(self) -> None: self.assertEqual("Sample datasource", single_datasource.name) self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", single_datasource.project_id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_datasource.owner_id) - self.assertEqual(set(["world", "indicators", "sample"]), single_datasource.tags) + self.assertEqual({"world", "indicators", "sample"}, single_datasource.tags) self.assertEqual(TSC.DatasourceItem.AskDataEnablement.SiteDefault, single_datasource.ask_data_enablement) def test_update(self) -> None: @@ -488,7 +488,7 @@ def test_download_object(self) -> None: def test_download_sanitizes_name(self) -> None: filename = "Name,With,Commas.tds" - disposition = 'name="tableau_workbook"; filename="{}"'.format(filename) + disposition = f'name="tableau_workbook"; filename="{filename}"' with requests_mock.mock() as m: m.get( self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", @@ -659,7 +659,7 @@ def test_revisions(self) -> None: response_xml = read_xml_asset(REVISION_XML) with requests_mock.mock() as m: - m.get("{0}/{1}/revisions".format(self.baseurl, datasource.id), text=response_xml) + m.get(f"{self.baseurl}/{datasource.id}/revisions", text=response_xml) self.server.datasources.populate_revisions(datasource) revisions = datasource.revisions @@ -687,7 +687,7 @@ def test_delete_revision(self) -> None: datasource._id = "06b944d2-959d-4604-9305-12323c95e70e" with requests_mock.mock() as m: - m.delete("{0}/{1}/revisions/3".format(self.baseurl, datasource.id)) + m.delete(f"{self.baseurl}/{datasource.id}/revisions/3") self.server.datasources.delete_revision(datasource.id, "3") def test_download_revision(self) -> None: diff --git a/test/test_endpoint.py b/test/test_endpoint.py index 8635af978..ff1ef0f72 100644 --- a/test/test_endpoint.py +++ b/test/test_endpoint.py @@ -54,7 +54,7 @@ def test_get_request_stream(self) -> None: self.assertFalse(response._content_consumed) def test_binary_log_truncated(self): - class FakeResponse(object): + class FakeResponse: headers = {"Content-Type": "application/octet-stream"} content = b"\x1337" * 1000 status_code = 200 diff --git a/test/test_favorites.py b/test/test_favorites.py index 6f0be3b3c..87332d70f 100644 --- a/test/test_favorites.py +++ b/test/test_favorites.py @@ -28,7 +28,7 @@ def setUp(self): def test_get(self) -> None: response_xml = read_xml_asset(GET_FAVORITES_XML) with requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) + m.get(f"{self.baseurl}/{self.user.id}", text=response_xml) self.server.favorites.get(self.user) self.assertIsNotNone(self.user._favorites) self.assertEqual(len(self.user.favorites["workbooks"]), 1) @@ -54,7 +54,7 @@ def test_add_favorite_workbook(self) -> None: workbook._id = "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00" workbook.name = "Superstore" with requests_mock.mock() as m: - m.put("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) + m.put(f"{self.baseurl}/{self.user.id}", text=response_xml) self.server.favorites.add_favorite_workbook(self.user, workbook) def test_add_favorite_view(self) -> None: @@ -63,7 +63,7 @@ def test_add_favorite_view(self) -> None: view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" view._name = "ENDANGERED SAFARI" with requests_mock.mock() as m: - m.put("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) + m.put(f"{self.baseurl}/{self.user.id}", text=response_xml) self.server.favorites.add_favorite_view(self.user, view) def test_add_favorite_datasource(self) -> None: @@ -72,7 +72,7 @@ def test_add_favorite_datasource(self) -> None: datasource._id = "e76a1461-3b1d-4588-bf1b-17551a879ad9" datasource.name = "SampleDS" with requests_mock.mock() as m: - m.put("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) + m.put(f"{self.baseurl}/{self.user.id}", text=response_xml) self.server.favorites.add_favorite_datasource(self.user, datasource) def test_add_favorite_project(self) -> None: @@ -82,7 +82,7 @@ def test_add_favorite_project(self) -> None: project = TSC.ProjectItem("Tableau") project._id = "1d0304cd-3796-429f-b815-7258370b9b74" with requests_mock.mock() as m: - m.put("{0}/{1}".format(baseurl, self.user.id), text=response_xml) + m.put(f"{baseurl}/{self.user.id}", text=response_xml) self.server.favorites.add_favorite_project(self.user, project) def test_delete_favorite_workbook(self) -> None: @@ -90,7 +90,7 @@ def test_delete_favorite_workbook(self) -> None: workbook._id = "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00" workbook.name = "Superstore" with requests_mock.mock() as m: - m.delete("{0}/{1}/workbooks/{2}".format(self.baseurl, self.user.id, workbook.id)) + m.delete(f"{self.baseurl}/{self.user.id}/workbooks/{workbook.id}") self.server.favorites.delete_favorite_workbook(self.user, workbook) def test_delete_favorite_view(self) -> None: @@ -98,7 +98,7 @@ def test_delete_favorite_view(self) -> None: view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" view._name = "ENDANGERED SAFARI" with requests_mock.mock() as m: - m.delete("{0}/{1}/views/{2}".format(self.baseurl, self.user.id, view.id)) + m.delete(f"{self.baseurl}/{self.user.id}/views/{view.id}") self.server.favorites.delete_favorite_view(self.user, view) def test_delete_favorite_datasource(self) -> None: @@ -106,7 +106,7 @@ def test_delete_favorite_datasource(self) -> None: datasource._id = "e76a1461-3b1d-4588-bf1b-17551a879ad9" datasource.name = "SampleDS" with requests_mock.mock() as m: - m.delete("{0}/{1}/datasources/{2}".format(self.baseurl, self.user.id, datasource.id)) + m.delete(f"{self.baseurl}/{self.user.id}/datasources/{datasource.id}") self.server.favorites.delete_favorite_datasource(self.user, datasource) def test_delete_favorite_project(self) -> None: @@ -115,5 +115,5 @@ def test_delete_favorite_project(self) -> None: project = TSC.ProjectItem("Tableau") project._id = "1d0304cd-3796-429f-b815-7258370b9b74" with requests_mock.mock() as m: - m.delete("{0}/{1}/projects/{2}".format(baseurl, self.user.id, project.id)) + m.delete(f"{baseurl}/{self.user.id}/projects/{project.id}") self.server.favorites.delete_favorite_project(self.user, project) diff --git a/test/test_filesys_helpers.py b/test/test_filesys_helpers.py index 4c8fb0f9f..0f3234d5d 100644 --- a/test/test_filesys_helpers.py +++ b/test/test_filesys_helpers.py @@ -37,7 +37,7 @@ def test_get_file_type_identifies_a_zip_file(self): with BytesIO() as file_object: with ZipFile(file_object, "w") as zf: with BytesIO() as stream: - stream.write("This is a zip file".encode()) + stream.write(b"This is a zip file") zf.writestr("dummy_file", stream.getbuffer()) file_object.seek(0) file_type = get_file_type(file_object) diff --git a/test/test_fileuploads.py b/test/test_fileuploads.py index 50a5ef48b..9567bc3ad 100644 --- a/test/test_fileuploads.py +++ b/test/test_fileuploads.py @@ -33,7 +33,7 @@ def setUp(self): self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - self.baseurl = "{}/sites/{}/fileUploads".format(self.server.baseurl, self.server.site_id) + self.baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/fileUploads" def test_read_chunks_file_path(self): file_path = asset("SampleWB.twbx") @@ -57,7 +57,7 @@ def test_upload_chunks_file_path(self): append_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=initialize_response_xml) - m.put("{}/{}".format(self.baseurl, upload_id), text=append_response_xml) + m.put(f"{self.baseurl}/{upload_id}", text=append_response_xml) actual = self.server.fileuploads.upload(file_path) self.assertEqual(upload_id, actual) @@ -72,7 +72,7 @@ def test_upload_chunks_file_object(self): append_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=initialize_response_xml) - m.put("{}/{}".format(self.baseurl, upload_id), text=append_response_xml) + m.put(f"{self.baseurl}/{upload_id}", text=append_response_xml) actual = self.server.fileuploads.upload(file_content) self.assertEqual(upload_id, actual) diff --git a/test/test_flowruns.py b/test/test_flowruns.py index 864c0d3cd..8af2540dc 100644 --- a/test/test_flowruns.py +++ b/test/test_flowruns.py @@ -1,3 +1,4 @@ +import sys import unittest import requests_mock @@ -5,7 +6,7 @@ import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime from tableauserverclient.server.endpoint.exceptions import FlowRunFailedException -from ._utils import read_xml_asset, mocked_time +from ._utils import read_xml_asset, mocked_time, server_response_error_factory GET_XML = "flow_runs_get.xml" GET_BY_ID_XML = "flow_runs_get_by_id.xml" @@ -28,9 +29,8 @@ def test_get(self) -> None: response_xml = read_xml_asset(GET_XML) with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) - all_flow_runs, pagination_item = self.server.flow_runs.get() + all_flow_runs = self.server.flow_runs.get() - self.assertEqual(2, pagination_item.total_available) self.assertEqual("cc2e652d-4a9b-4476-8c93-b238c45db968", all_flow_runs[0].id) self.assertEqual("2021-02-11T01:42:55Z", format_datetime(all_flow_runs[0].started_at)) self.assertEqual("2021-02-11T01:57:38Z", format_datetime(all_flow_runs[0].completed_at)) @@ -75,7 +75,7 @@ def test_wait_for_job_finished(self) -> None: response_xml = read_xml_asset(GET_BY_ID_XML) flow_run_id = "cc2e652d-4a9b-4476-8c93-b238c45db968" with mocked_time(), requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, flow_run_id), text=response_xml) + m.get(f"{self.baseurl}/{flow_run_id}", text=response_xml) flow_run = self.server.flow_runs.wait_for_job(flow_run_id) self.assertEqual(flow_run_id, flow_run.id) @@ -86,7 +86,7 @@ def test_wait_for_job_failed(self) -> None: response_xml = read_xml_asset(GET_BY_ID_FAILED_XML) flow_run_id = "c2b35d5a-e130-471a-aec8-7bc5435fe0e7" with mocked_time(), requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, flow_run_id), text=response_xml) + m.get(f"{self.baseurl}/{flow_run_id}", text=response_xml) with self.assertRaises(FlowRunFailedException): self.server.flow_runs.wait_for_job(flow_run_id) @@ -95,6 +95,17 @@ def test_wait_for_job_timeout(self) -> None: response_xml = read_xml_asset(GET_BY_ID_INPROGRESS_XML) flow_run_id = "71afc22c-9c06-40be-8d0f-4c4166d29e6c" with mocked_time(), requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, flow_run_id), text=response_xml) + m.get(f"{self.baseurl}/{flow_run_id}", text=response_xml) with self.assertRaises(TimeoutError): self.server.flow_runs.wait_for_job(flow_run_id, timeout=30) + + def test_queryset(self) -> None: + response_xml = read_xml_asset(GET_XML) + error_response = server_response_error_factory( + "400006", "Bad Request", "0xB4EAB088 : The start index '9900' is greater than or equal to the total count.)" + ) + with requests_mock.mock() as m: + m.get(f"{self.baseurl}?pageNumber=1", text=response_xml) + m.get(f"{self.baseurl}?pageNumber=2", text=error_response) + queryset = self.server.flow_runs.all() + assert len(queryset) == sys.maxsize diff --git a/test/test_flowtask.py b/test/test_flowtask.py index 034066e64..2d9f7c7bd 100644 --- a/test/test_flowtask.py +++ b/test/test_flowtask.py @@ -40,7 +40,7 @@ def test_create_flow_task(self): 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) + m.post(f"{self.baseurl}", text=response_xml) create_response_content = self.server.flow_tasks.create(task).decode("utf-8") self.assertTrue("schedule_id" in create_response_content) diff --git a/test/test_group.py b/test/test_group.py index fc9c75a6d..41b5992be 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -1,4 +1,3 @@ -# encoding=utf-8 from pathlib import Path import unittest import os diff --git a/test/test_job.py b/test/test_job.py index d86397086..20b238764 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -51,7 +51,7 @@ def test_get_by_id(self) -> None: response_xml = read_xml_asset(GET_BY_ID_XML) job_id = "2eef4225-aa0c-41c4-8662-a76d89ed7336" with requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) + m.get(f"{self.baseurl}/{job_id}", text=response_xml) job = self.server.jobs.get_by_id(job_id) updated_at = datetime(2020, 5, 13, 20, 25, 18, tzinfo=utc) @@ -81,7 +81,7 @@ def test_wait_for_job_finished(self) -> None: response_xml = read_xml_asset(GET_BY_ID_XML) job_id = "2eef4225-aa0c-41c4-8662-a76d89ed7336" with mocked_time(), requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) + m.get(f"{self.baseurl}/{job_id}", text=response_xml) job = self.server.jobs.wait_for_job(job_id) self.assertEqual(job_id, job.id) @@ -92,7 +92,7 @@ def test_wait_for_job_failed(self) -> None: response_xml = read_xml_asset(GET_BY_ID_FAILED_XML) job_id = "77d5e57a-2517-479f-9a3c-a32025f2b64d" with mocked_time(), requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) + m.get(f"{self.baseurl}/{job_id}", text=response_xml) with self.assertRaises(JobFailedException): self.server.jobs.wait_for_job(job_id) @@ -101,7 +101,7 @@ def test_wait_for_job_timeout(self) -> None: response_xml = read_xml_asset(GET_BY_ID_INPROGRESS_XML) job_id = "77d5e57a-2517-479f-9a3c-a32025f2b64d" with mocked_time(), requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) + m.get(f"{self.baseurl}/{job_id}", text=response_xml) with self.assertRaises(TimeoutError): self.server.jobs.wait_for_job(job_id, timeout=30) diff --git a/test/test_pager.py b/test/test_pager.py index c30352809..1836095bb 100644 --- a/test/test_pager.py +++ b/test/test_pager.py @@ -1,6 +1,7 @@ import contextlib import os import unittest +import xml.etree.ElementTree as ET import requests_mock @@ -122,3 +123,14 @@ def test_pager_view(self) -> None: m.get(self.server.views.baseurl, text=view_xml) for view in TSC.Pager(self.server.views): assert view.name is not None + + def test_queryset_no_matches(self) -> None: + elem = ET.Element("tsResponse", xmlns="http://tableau.com/api") + ET.SubElement(elem, "pagination", totalAvailable="0") + ET.SubElement(elem, "groups") + xml = ET.tostring(elem).decode("utf-8") + with requests_mock.mock() as m: + m.get(self.server.groups.baseurl, text=xml) + all_groups = self.server.groups.all() + groups = list(all_groups) + assert len(groups) == 0 diff --git a/test/test_project.py b/test/test_project.py index e05785f86..430db84b2 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -241,9 +241,9 @@ def test_delete_permission(self) -> None: rules = TSC.PermissionsRule(grantee=GroupItem.as_reference(single_group._id), capabilities=capabilities) - endpoint = "{}/permissions/groups/{}".format(single_project._id, single_group._id) - m.delete("{}/{}/Read/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/Write/Allow".format(self.baseurl, endpoint), status_code=204) + endpoint = f"{single_project._id}/permissions/groups/{single_group._id}" + m.delete(f"{self.baseurl}/{endpoint}/Read/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/Write/Allow", status_code=204) self.server.projects.delete_permission(item=single_project, rules=rules) def test_delete_workbook_default_permission(self) -> None: @@ -287,19 +287,19 @@ def test_delete_workbook_default_permission(self) -> None: rules = TSC.PermissionsRule(grantee=GroupItem.as_reference(single_group._id), capabilities=capabilities) - endpoint = "{}/default-permissions/workbooks/groups/{}".format(single_project._id, single_group._id) - m.delete("{}/{}/Read/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ExportImage/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ExportData/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ViewComments/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/AddComment/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/Filter/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ViewUnderlyingData/Deny".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ShareView/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/WebAuthoring/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/Write/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ExportXml/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ChangeHierarchy/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/Delete/Deny".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ChangePermissions/Allow".format(self.baseurl, endpoint), status_code=204) + endpoint = f"{single_project._id}/default-permissions/workbooks/groups/{single_group._id}" + m.delete(f"{self.baseurl}/{endpoint}/Read/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ExportImage/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ExportData/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ViewComments/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/AddComment/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/Filter/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ViewUnderlyingData/Deny", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ShareView/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/WebAuthoring/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/Write/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ExportXml/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ChangeHierarchy/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/Delete/Deny", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ChangePermissions/Allow", status_code=204) self.server.projects.delete_workbook_default_permissions(item=single_project, rule=rules) diff --git a/test/test_regression_tests.py b/test/test_regression_tests.py index 772704f69..62e301591 100644 --- a/test/test_regression_tests.py +++ b/test/test_regression_tests.py @@ -1,9 +1,5 @@ import unittest - -try: - from unittest import mock -except ImportError: - import mock # type: ignore[no-redef] +from unittest import mock import tableauserverclient.server.request_factory as factory from tableauserverclient.helpers.strings import redact_xml diff --git a/test/test_request_option.py b/test/test_request_option.py index e48f8510a..7405189a3 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -31,7 +31,7 @@ def setUp(self) -> None: self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - self.baseurl = "{0}/{1}".format(self.server.sites.baseurl, self.server._site_id) + self.baseurl = f"{self.server.sites.baseurl}/{self.server._site_id}" def test_pagination(self) -> None: with open(PAGINATION_XML, "rb") as f: @@ -112,9 +112,9 @@ def test_filter_tags_in(self) -> None: matching_workbooks, pagination_item = self.server.workbooks.get(req_option) self.assertEqual(3, pagination_item.total_available) - self.assertEqual(set(["weather"]), matching_workbooks[0].tags) - self.assertEqual(set(["safari"]), matching_workbooks[1].tags) - self.assertEqual(set(["sample"]), matching_workbooks[2].tags) + self.assertEqual({"weather"}, matching_workbooks[0].tags) + self.assertEqual({"safari"}, matching_workbooks[1].tags) + self.assertEqual({"sample"}, matching_workbooks[2].tags) # check if filtered projects with spaces & special characters # get correctly returned @@ -148,9 +148,9 @@ def test_filter_tags_in_shorthand(self) -> None: matching_workbooks = self.server.workbooks.filter(tags__in=["sample", "safari", "weather"]) self.assertEqual(3, matching_workbooks.total_available) - self.assertEqual(set(["weather"]), matching_workbooks[0].tags) - self.assertEqual(set(["safari"]), matching_workbooks[1].tags) - self.assertEqual(set(["sample"]), matching_workbooks[2].tags) + self.assertEqual({"weather"}, matching_workbooks[0].tags) + self.assertEqual({"safari"}, matching_workbooks[1].tags) + self.assertEqual({"sample"}, matching_workbooks[2].tags) def test_invalid_shorthand_option(self) -> None: with self.assertRaises(ValueError): @@ -358,3 +358,13 @@ def test_queryset_pagesize_filter(self) -> None: queryset = self.server.views.all().filter(page_size=page_size) assert queryset.request_options.pagesize == page_size _ = list(queryset) + + def test_language_export(self) -> None: + with requests_mock.mock() as m: + m.get(requests_mock.ANY) + url = self.baseurl + "/views/456/data" + opts = TSC.PDFRequestOptions() + opts.language = "en-US" + + resp = self.server.users.get_request(url, request_object=opts) + self.assertTrue(re.search("language=en-us", resp.request.query)) diff --git a/test/test_schedule.py b/test/test_schedule.py index 0377295d7..b072522a4 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -106,7 +106,7 @@ def test_get_by_id(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -120,7 +120,7 @@ def test_get_hourly_by_id(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -135,7 +135,7 @@ def test_get_daily_by_id(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -150,7 +150,7 @@ def test_get_monthly_by_id(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -165,7 +165,7 @@ def test_get_monthly_by_id_2(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "8c5caf33-6223-4724-83c3-ccdc1e730a07" - baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -347,7 +347,7 @@ def test_update_after_get(self) -> None: def test_add_workbook(self) -> None: self.server.version = "2.8" - baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) + baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" with open(WORKBOOK_GET_BY_ID_XML, "rb") as f: workbook_response = f.read().decode("utf-8") @@ -362,7 +362,7 @@ def test_add_workbook(self) -> None: def test_add_workbook_with_warnings(self) -> None: self.server.version = "2.8" - baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) + baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" with open(WORKBOOK_GET_BY_ID_XML, "rb") as f: workbook_response = f.read().decode("utf-8") @@ -378,7 +378,7 @@ def test_add_workbook_with_warnings(self) -> None: def test_add_datasource(self) -> None: self.server.version = "2.8" - baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) + baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" with open(DATASOURCE_GET_BY_ID_XML, "rb") as f: datasource_response = f.read().decode("utf-8") @@ -393,7 +393,7 @@ def test_add_datasource(self) -> None: def test_add_flow(self) -> None: self.server.version = "3.3" - baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) + baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" with open(FLOW_GET_BY_ID_XML, "rb") as f: flow_response = f.read().decode("utf-8") diff --git a/test/test_server_info.py b/test/test_server_info.py index 1cf190ecd..fa1472c9a 100644 --- a/test/test_server_info.py +++ b/test/test_server_info.py @@ -4,6 +4,7 @@ import requests_mock import tableauserverclient as TSC +from tableauserverclient.server.endpoint.exceptions import NonXMLResponseError TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -11,6 +12,7 @@ SERVER_INFO_25_XML = os.path.join(TEST_ASSET_DIR, "server_info_25.xml") SERVER_INFO_404 = os.path.join(TEST_ASSET_DIR, "server_info_404.xml") SERVER_INFO_AUTH_INFO_XML = os.path.join(TEST_ASSET_DIR, "server_info_auth_info.xml") +SERVER_INFO_WRONG_SITE = os.path.join(TEST_ASSET_DIR, "server_info_wrong_site.html") class ServerInfoTests(unittest.TestCase): @@ -63,3 +65,11 @@ def test_server_use_server_version_flag(self): m.get("http://test/api/2.4/serverInfo", text=si_response_xml) server = TSC.Server("http://test", use_server_version=True) self.assertEqual(server.version, "2.5") + + def test_server_wrong_site(self): + with open(SERVER_INFO_WRONG_SITE, "rb") as f: + response = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get(self.server.server_info.baseurl, text=response, status_code=404) + with self.assertRaises(NonXMLResponseError): + self.server.server_info.get() diff --git a/test/test_site_model.py b/test/test_site_model.py index f62eb66f0..60ad9c5e5 100644 --- a/test/test_site_model.py +++ b/test/test_site_model.py @@ -1,5 +1,3 @@ -# coding=utf-8 - import unittest import tableauserverclient as TSC diff --git a/test/test_tagging.py b/test/test_tagging.py index 0184af415..23dffebfb 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -1,6 +1,6 @@ from contextlib import ExitStack import re -from typing import Iterable +from collections.abc import Iterable import uuid from xml.etree import ElementTree as ET @@ -172,7 +172,7 @@ def test_update_tags(get_server, endpoint_type, item, tags) -> None: if isinstance(item, str): stack.enter_context(pytest.raises((ValueError, NotImplementedError))) elif hasattr(item, "_initial_tags"): - initial_tags = set(["x", "y", "z"]) + initial_tags = {"x", "y", "z"} item._initial_tags = initial_tags add_tags_xml = add_tag_xml_response_factory(tags - initial_tags) delete_tags_xml = add_tag_xml_response_factory(initial_tags - tags) diff --git a/test/test_task.py b/test/test_task.py index 53da7c160..2d724b879 100644 --- a/test/test_task.py +++ b/test/test_task.py @@ -119,7 +119,7 @@ def test_get_materializeviews_tasks(self): with open(GET_XML_DATAACCELERATION_TASK, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get("{}/{}".format(self.server.tasks.baseurl, TaskItem.Type.DataAcceleration), text=response_xml) + m.get(f"{self.server.tasks.baseurl}/{TaskItem.Type.DataAcceleration}", text=response_xml) all_tasks, pagination_item = self.server.tasks.get(task_type=TaskItem.Type.DataAcceleration) task = all_tasks[0] @@ -145,7 +145,7 @@ def test_get_by_id(self): response_xml = f.read().decode("utf-8") task_id = "f84901ac-72ad-4f9b-a87e-7a3500402ad6" with requests_mock.mock() as m: - m.get("{}/{}".format(self.baseurl, task_id), text=response_xml) + m.get(f"{self.baseurl}/{task_id}", text=response_xml) task = self.server.tasks.get_by_id(task_id) self.assertEqual("c7a9327e-1cda-4504-b026-ddb43b976d1d", task.target.id) @@ -159,7 +159,7 @@ def test_run_now(self): with open(GET_XML_RUN_NOW_RESPONSE, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post("{}/{}/runNow".format(self.baseurl, task_id), text=response_xml) + m.post(f"{self.baseurl}/{task_id}/runNow", text=response_xml) job_response_content = self.server.tasks.run(task).decode("utf-8") self.assertTrue("7b6b59a8-ac3c-4d1d-2e9e-0b5b4ba8a7b6" in job_response_content) @@ -181,7 +181,7 @@ def test_create_extract_task(self): 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) + m.post(f"{self.baseurl}", text=response_xml) create_response_content = self.server.tasks.create(task).decode("utf-8") self.assertTrue("task_id" in create_response_content) diff --git a/test/test_user.py b/test/test_user.py index 1f5eba57f..a46624845 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -1,8 +1,5 @@ -import io import os import unittest -from typing import List -from unittest.mock import MagicMock import requests_mock @@ -163,7 +160,7 @@ def test_populate_workbooks(self) -> None: self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", workbook_list[0].project_id) self.assertEqual("default", workbook_list[0].project_name) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", workbook_list[0].owner_id) - self.assertEqual(set(["Safari", "Sample"]), workbook_list[0].tags) + self.assertEqual({"Safari", "Sample"}, workbook_list[0].tags) def test_populate_workbooks_missing_id(self) -> None: single_user = TSC.UserItem("test", "Interactor") @@ -176,7 +173,7 @@ def test_populate_favorites(self) -> None: with open(GET_FAVORITES_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get("{0}/{1}".format(baseurl, single_user.id), text=response_xml) + m.get(f"{baseurl}/{single_user.id}", text=response_xml) self.server.users.populate_favorites(single_user) self.assertIsNotNone(single_user._favorites) self.assertEqual(len(single_user.favorites["workbooks"]), 1) diff --git a/test/test_user_model.py b/test/test_user_model.py index d0997b9ff..a8a2c51cb 100644 --- a/test/test_user_model.py +++ b/test/test_user_model.py @@ -1,7 +1,6 @@ import logging import unittest from unittest.mock import * -from typing import List import io import pytest @@ -107,7 +106,7 @@ def test_validate_user_detail_standard(self): TSC.UserItem.CSVImport.create_user_from_line(test_line) # for file handling - def _mock_file_content(self, content: List[str]) -> io.TextIOWrapper: + def _mock_file_content(self, content: list[str]) -> io.TextIOWrapper: # the empty string represents EOF # the tests run through the file twice, first to validate then to fetch mock = MagicMock(io.TextIOWrapper) @@ -119,10 +118,10 @@ def _mock_file_content(self, content: List[str]) -> io.TextIOWrapper: def test_validate_import_file(self): test_data = self._mock_file_content(UserDataTest.valid_import_content) valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, UserDataTest.logger) - assert valid == 2, "Expected two lines to be parsed, got {}".format(valid) - assert invalid == [], "Expected no failures, got {}".format(invalid) + assert valid == 2, f"Expected two lines to be parsed, got {valid}" + assert invalid == [], f"Expected no failures, got {invalid}" def test_validate_usernames_file(self): test_data = self._mock_file_content(UserDataTest.usernames) valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, UserDataTest.logger) - assert valid == 5, "Exactly 5 of the lines were valid, counted {}".format(valid + invalid) + assert valid == 5, f"Exactly 5 of the lines were valid, counted {valid + invalid}" diff --git a/test/test_view.py b/test/test_view.py index 1c667a4c3..a89a6d235 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -49,7 +49,7 @@ def test_get(self) -> None: self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", all_views[0].workbook_id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_views[0].owner_id) self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", all_views[0].project_id) - self.assertEqual(set(["tag1", "tag2"]), all_views[0].tags) + self.assertEqual({"tag1", "tag2"}, all_views[0].tags) self.assertIsNone(all_views[0].created_at) self.assertIsNone(all_views[0].updated_at) self.assertIsNone(all_views[0].sheet_type) @@ -77,7 +77,7 @@ def test_get_by_id(self) -> None: 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({"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) @@ -95,7 +95,7 @@ def test_get_by_id_usage(self) -> None: 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({"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) diff --git a/test/test_view_acceleration.py b/test/test_view_acceleration.py index 6f94f0c10..766831b0a 100644 --- a/test/test_view_acceleration.py +++ b/test/test_view_acceleration.py @@ -42,7 +42,7 @@ def test_get_by_id(self) -> None: 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({"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) diff --git a/test/test_workbook.py b/test/test_workbook.py index 950118dc0..1a6b3192f 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -83,7 +83,7 @@ def test_get(self) -> None: self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", all_workbooks[1].project_id) self.assertEqual("default", all_workbooks[1].project_name) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_workbooks[1].owner_id) - self.assertEqual(set(["Safari", "Sample"]), all_workbooks[1].tags) + self.assertEqual({"Safari", "Sample"}, all_workbooks[1].tags) def test_get_ignore_invalid_date(self) -> None: with open(GET_INVALID_DATE_XML, "rb") as f: @@ -127,7 +127,7 @@ def test_get_by_id(self) -> None: 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({"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) @@ -152,7 +152,7 @@ def test_get_by_id_personal(self) -> None: self.assertTrue(single_workbook.project_id) self.assertIsNone(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({"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) @@ -277,7 +277,7 @@ def test_download_object(self) -> None: def test_download_sanitizes_name(self) -> None: filename = "Name,With,Commas.twbx" - disposition = 'name="tableau_workbook"; filename="{}"'.format(filename) + disposition = f'name="tableau_workbook"; filename="{filename}"' with requests_mock.mock() as m: m.get( self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", @@ -817,7 +817,7 @@ def test_revisions(self) -> None: with open(REVISION_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get("{0}/{1}/revisions".format(self.baseurl, workbook.id), text=response_xml) + m.get(f"{self.baseurl}/{workbook.id}/revisions", text=response_xml) self.server.workbooks.populate_revisions(workbook) revisions = workbook.revisions @@ -846,7 +846,7 @@ def test_delete_revision(self) -> None: workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" with requests_mock.mock() as m: - m.delete("{0}/{1}/revisions/3".format(self.baseurl, workbook.id)) + m.delete(f"{self.baseurl}/{workbook.id}/revisions/3") self.server.workbooks.delete_revision(workbook.id, "3") def test_download_revision(self) -> None: diff --git a/versioneer.py b/versioneer.py index 86c240e13..cce899f58 100644 --- a/versioneer.py +++ b/versioneer.py @@ -276,7 +276,6 @@ """ -from __future__ import print_function try: import configparser @@ -328,7 +327,7 @@ def get_root(): me_dir = os.path.normcase(os.path.splitext(me)[0]) vsr_dir = os.path.normcase(os.path.splitext(versioneer_py)[0]) if me_dir != vsr_dir: - print("Warning: build in %s is using versioneer.py from %s" % (os.path.dirname(me), versioneer_py)) + print(f"Warning: build in {os.path.dirname(me)} is using versioneer.py from {versioneer_py}") except NameError: pass return root @@ -342,7 +341,7 @@ def get_config_from_root(root): # the top of versioneer.py for instructions on writing your setup.cfg . setup_cfg = os.path.join(root, "setup.cfg") parser = configparser.SafeConfigParser() - with open(setup_cfg, "r") as f: + with open(setup_cfg) as f: parser.readfp(f) VCS = parser.get("versioneer", "VCS") # mandatory @@ -398,7 +397,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= [c] + args, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=(subprocess.PIPE if hide_stderr else None) ) break - except EnvironmentError: + except OSError: e = sys.exc_info()[1] if e.errno == errno.ENOENT: continue @@ -408,7 +407,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= return None, None else: if verbose: - print("unable to find command, tried %s" % (commands,)) + print(f"unable to find command, tried {commands}" return None, None stdout = p.communicate()[0].strip() if sys.version_info[0] >= 3: @@ -423,7 +422,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= LONG_VERSION_PY[ "git" -] = ''' +] = r''' # This file helps to compute a version number in source trees obtained from # git-archive tarball (such as those provided by githubs download-from-tag # feature). Distribution tarballs (built by setup.py sdist) and build @@ -955,7 +954,7 @@ def git_get_keywords(versionfile_abs): # _version.py. keywords = {} try: - f = open(versionfile_abs, "r") + f = open(versionfile_abs) for line in f.readlines(): if line.strip().startswith("git_refnames ="): mo = re.search(r'=\s*"(.*)"', line) @@ -970,7 +969,7 @@ def git_get_keywords(versionfile_abs): if mo: keywords["date"] = mo.group(1) f.close() - except EnvironmentError: + except OSError: pass return keywords @@ -994,11 +993,11 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): if verbose: print("keywords are unexpanded, not using") raise NotThisMethod("unexpanded keywords, not a git-archive tarball") - refs = set([r.strip() for r in refnames.strip("()").split(",")]) + refs = {r.strip() for r in refnames.strip("()").split(",")} # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. TAG = "tag: " - tags = set([r[len(TAG) :] for r in refs if r.startswith(TAG)]) + tags = {r[len(TAG) :] for r in refs if r.startswith(TAG)} if not tags: # Either we're using git < 1.8.3, or there really are no tags. We use # a heuristic: assume all version tags have a digit. The old git %d @@ -1007,7 +1006,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # between branches and tags. By ignoring refnames without digits, we # filter out many common branch names like "release" and # "stabilization", as well as "HEAD" and "master". - tags = set([r for r in refs if re.search(r"\d", r)]) + tags = {r for r in refs if re.search(r"\d", r)} if verbose: print("discarding '%s', no digits" % ",".join(refs - tags)) if verbose: @@ -1100,7 +1099,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if verbose: fmt = "tag '%s' doesn't start with prefix '%s'" print(fmt % (full_tag, tag_prefix)) - pieces["error"] = "tag '%s' doesn't start with prefix '%s'" % (full_tag, tag_prefix) + pieces["error"] = f"tag '{full_tag}' doesn't start with prefix '{tag_prefix}'" return pieces pieces["closest-tag"] = full_tag[len(tag_prefix) :] @@ -1145,13 +1144,13 @@ def do_vcs_install(manifest_in, versionfile_source, ipy): files.append(versioneer_file) present = False try: - f = open(".gitattributes", "r") + f = open(".gitattributes") for line in f.readlines(): if line.strip().startswith(versionfile_source): if "export-subst" in line.strip().split()[1:]: present = True f.close() - except EnvironmentError: + except OSError: pass if not present: f = open(".gitattributes", "a+") @@ -1185,7 +1184,7 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): root = os.path.dirname(root) # up a level if verbose: - print("Tried directories %s but none started with prefix %s" % (str(rootdirs), parentdir_prefix)) + print(f"Tried directories {rootdirs!s} but none started with prefix {parentdir_prefix}") raise NotThisMethod("rootdir doesn't start with parentdir_prefix") @@ -1212,7 +1211,7 @@ def versions_from_file(filename): try: with open(filename) as f: contents = f.read() - except EnvironmentError: + except OSError: raise NotThisMethod("unable to read _version.py") mo = re.search(r"version_json = '''\n(.*)''' # END VERSION_JSON", contents, re.M | re.S) if not mo: @@ -1229,7 +1228,7 @@ def write_to_version_file(filename, versions): with open(filename, "w") as f: f.write(SHORT_VERSION_PY % contents) - print("set %s to '%s'" % (filename, versions["version"])) + print(f"set {filename} to '{versions['version']}'") def plus_or_dot(pieces): @@ -1452,7 +1451,7 @@ def get_versions(verbose=False): try: ver = versions_from_file(versionfile_abs) if verbose: - print("got version from file %s %s" % (versionfile_abs, ver)) + print(f"got version from file {versionfile_abs} {ver}") return ver except NotThisMethod: pass @@ -1723,7 +1722,7 @@ def do_setup(): root = get_root() try: cfg = get_config_from_root(root) - except (EnvironmentError, configparser.NoSectionError, configparser.NoOptionError) as e: + except (OSError, configparser.NoSectionError, configparser.NoOptionError) as e: if isinstance(e, (EnvironmentError, configparser.NoSectionError)): print("Adding sample versioneer config to setup.cfg", file=sys.stderr) with open(os.path.join(root, "setup.cfg"), "a") as f: @@ -1748,9 +1747,9 @@ def do_setup(): ipy = os.path.join(os.path.dirname(cfg.versionfile_source), "__init__.py") if os.path.exists(ipy): try: - with open(ipy, "r") as f: + with open(ipy) as f: old = f.read() - except EnvironmentError: + except OSError: old = "" if INIT_PY_SNIPPET not in old: print(" appending to %s" % ipy) @@ -1769,12 +1768,12 @@ def do_setup(): manifest_in = os.path.join(root, "MANIFEST.in") simple_includes = set() try: - with open(manifest_in, "r") as f: + with open(manifest_in) as f: for line in f: if line.startswith("include "): for include in line.split()[1:]: simple_includes.add(include) - except EnvironmentError: + except OSError: pass # That doesn't cover everything MANIFEST.in can do # (http://docs.python.org/2/distutils/sourcedist.html#commands), so @@ -1805,7 +1804,7 @@ def scan_setup_py(): found = set() setters = False errors = 0 - with open("setup.py", "r") as f: + with open("setup.py") as f: for line in f.readlines(): if "import versioneer" in line: found.add("import") From 196d73a08035cc0d8be243d8f9c76f4265b36057 Mon Sep 17 00:00:00 2001 From: renoyjohnm <168143499+renoyjohnm@users.noreply.github.com> Date: Thu, 24 Oct 2024 13:42:25 -0700 Subject: [PATCH 261/296] Revert "Development to master branch merge" (#1513) Revert "0.34 development merge" This reverts commit 6798b9e5fc27d459fea93b415519331c7adac954. --- .github/workflows/meta-checks.yml | 14 - .github/workflows/run-tests.yml | 16 +- pyproject.toml | 18 +- samples/add_default_permission.py | 4 +- samples/create_group.py | 13 +- samples/create_project.py | 2 +- samples/create_schedules.py | 8 +- samples/explore_datasource.py | 22 +- samples/explore_favorites.py | 16 +- samples/explore_site.py | 2 +- samples/explore_webhooks.py | 4 +- samples/explore_workbook.py | 33 +- samples/export.py | 23 +- samples/extracts.py | 14 +- samples/filter_sort_groups.py | 44 +- samples/filter_sort_projects.py | 2 +- samples/getting_started/1_hello_server.py | 4 +- samples/getting_started/2_hello_site.py | 4 +- samples/getting_started/3_hello_universe.py | 22 +- samples/initialize_server.py | 10 +- samples/list.py | 5 +- samples/login.py | 25 +- samples/move_workbook_sites.py | 8 +- samples/pagination_sample.py | 8 +- samples/publish_datasource.py | 25 +- samples/publish_workbook.py | 4 +- samples/query_permissions.py | 8 +- samples/refresh_tasks.py | 4 +- samples/set_refresh_schedule.py | 2 +- samples/update_connection.py | 2 +- samples/update_workbook_data_acceleration.py | 109 +++ .../update_workbook_data_freshness_policy.py | 2 +- tableauserverclient/__init__.py | 50 +- tableauserverclient/_version.py | 18 +- tableauserverclient/config.py | 8 +- tableauserverclient/models/column_item.py | 2 +- .../models/connection_credentials.py | 2 +- tableauserverclient/models/connection_item.py | 12 +- .../models/custom_view_item.py | 35 +- .../models/data_acceleration_report_item.py | 4 +- tableauserverclient/models/data_alert_item.py | 10 +- .../models/data_freshness_policy_item.py | 12 +- tableauserverclient/models/database_item.py | 6 +- tableauserverclient/models/datasource_item.py | 20 +- tableauserverclient/models/dqw_item.py | 2 +- tableauserverclient/models/favorites_item.py | 11 +- tableauserverclient/models/fileupload_item.py | 2 +- tableauserverclient/models/flow_item.py | 12 +- tableauserverclient/models/flow_run_item.py | 6 +- tableauserverclient/models/group_item.py | 8 +- tableauserverclient/models/groupset_item.py | 8 +- tableauserverclient/models/interval_item.py | 18 +- tableauserverclient/models/job_item.py | 16 +- .../models/linked_tasks_item.py | 10 +- tableauserverclient/models/metric_item.py | 10 +- tableauserverclient/models/pagination_item.py | 2 +- .../models/permissions_item.py | 22 +- tableauserverclient/models/project_item.py | 54 +- .../models/property_decorators.py | 23 +- tableauserverclient/models/reference_item.py | 4 +- tableauserverclient/models/revision_item.py | 6 +- tableauserverclient/models/schedule_item.py | 4 +- .../models/server_info_item.py | 32 +- tableauserverclient/models/site_item.py | 72 +- .../models/subscription_item.py | 6 +- tableauserverclient/models/table_item.py | 2 +- tableauserverclient/models/tableau_auth.py | 120 +-- tableauserverclient/models/tableau_types.py | 4 +- tableauserverclient/models/tag_item.py | 7 +- tableauserverclient/models/task_item.py | 8 +- tableauserverclient/models/user_item.py | 64 +- tableauserverclient/models/view_item.py | 21 +- .../models/virtual_connection_item.py | 11 +- tableauserverclient/models/webhook_item.py | 12 +- tableauserverclient/models/workbook_item.py | 102 +-- tableauserverclient/namespace.py | 2 +- tableauserverclient/server/__init__.py | 3 +- .../server/endpoint/auth_endpoint.py | 73 +- .../server/endpoint/custom_views_endpoint.py | 80 +- .../data_acceleration_report_endpoint.py | 4 +- .../server/endpoint/data_alert_endpoint.py | 28 +- .../server/endpoint/databases_endpoint.py | 25 +- .../server/endpoint/datasources_endpoint.py | 103 +-- .../endpoint/default_permissions_endpoint.py | 37 +- .../server/endpoint/dqw_endpoint.py | 18 +- .../server/endpoint/endpoint.py | 40 +- .../server/endpoint/exceptions.py | 30 +- .../server/endpoint/favorites_endpoint.py | 62 +- .../server/endpoint/fileuploads_endpoint.py | 20 +- .../server/endpoint/flow_runs_endpoint.py | 28 +- .../server/endpoint/flow_task_endpoint.py | 4 +- .../server/endpoint/flows_endpoint.py | 59 +- .../server/endpoint/groups_endpoint.py | 35 +- .../server/endpoint/groupsets_endpoint.py | 4 +- .../server/endpoint/jobs_endpoint.py | 14 +- .../server/endpoint/linked_tasks_endpoint.py | 4 +- .../server/endpoint/metadata_endpoint.py | 4 +- .../server/endpoint/metrics_endpoint.py | 20 +- .../server/endpoint/permissions_endpoint.py | 28 +- .../server/endpoint/projects_endpoint.py | 111 +-- .../server/endpoint/resource_tagger.py | 27 +- .../server/endpoint/schedules_endpoint.py | 35 +- .../server/endpoint/server_info_endpoint.py | 45 +- .../server/endpoint/sites_endpoint.py | 299 +------- .../server/endpoint/subscriptions_endpoint.py | 20 +- .../server/endpoint/tables_endpoint.py | 29 +- .../server/endpoint/tasks_endpoint.py | 16 +- .../server/endpoint/users_endpoint.py | 385 +--------- .../server/endpoint/views_endpoint.py | 37 +- .../endpoint/virtual_connections_endpoint.py | 11 +- .../server/endpoint/webhooks_endpoint.py | 22 +- .../server/endpoint/workbooks_endpoint.py | 708 ++---------------- tableauserverclient/server/filter.py | 4 +- tableauserverclient/server/pager.py | 11 +- tableauserverclient/server/query.py | 87 +-- tableauserverclient/server/request_factory.py | 73 +- tableauserverclient/server/request_options.py | 268 ++++--- tableauserverclient/server/server.py | 74 +- tableauserverclient/server/sort.py | 4 +- test/_utils.py | 14 - test/assets/flow_runs_get.xml | 3 +- test/assets/server_info_wrong_site.html | 56 -- test/test_auth.py | 6 +- test/test_custom_view.py | 72 -- test/test_dataalert.py | 2 +- test/test_datasource.py | 10 +- test/test_endpoint.py | 2 +- test/test_favorites.py | 18 +- test/test_filesys_helpers.py | 2 +- test/test_fileuploads.py | 6 +- test/test_flowruns.py | 23 +- test/test_flowtask.py | 2 +- test/test_group.py | 1 + test/test_job.py | 8 +- test/test_pager.py | 12 - test/test_project.py | 36 +- test/test_regression_tests.py | 6 +- test/test_request_option.py | 24 +- test/test_schedule.py | 18 +- test/test_server_info.py | 10 - test/test_site_model.py | 2 + test/test_tagging.py | 4 +- test/test_task.py | 8 +- test/test_user.py | 7 +- test/test_user_model.py | 9 +- test/test_view.py | 6 +- test/test_view_acceleration.py | 2 +- test/test_workbook.py | 12 +- versioneer.py | 47 +- 149 files changed, 1407 insertions(+), 3347 deletions(-) create mode 100644 samples/update_workbook_data_acceleration.py delete mode 100644 test/assets/server_info_wrong_site.html diff --git a/.github/workflows/meta-checks.yml b/.github/workflows/meta-checks.yml index 0e2b425ee..41a944e63 100644 --- a/.github/workflows/meta-checks.yml +++ b/.github/workflows/meta-checks.yml @@ -13,20 +13,6 @@ jobs: runs-on: ${{ matrix.os }} steps: - - name: Get pip cache dir - id: pip-cache - shell: bash - run: | - echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT - - - name: cache - uses: actions/cache@v4 - with: - path: ${{ steps.pip-cache.outputs.dir }} - key: ${{ runner.os }}-${{ matrix.python-version }}-pip-${{ hashFiles('pyproject.toml') }} - restore-keys: | - ${{ runner.os }}-${{ matrix.python-version }}-pip- - - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 2e197cf20..d70539582 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -13,25 +13,11 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] runs-on: ${{ matrix.os }} steps: - - name: Get pip cache dir - id: pip-cache - shell: bash - run: | - echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT - - - name: cache - uses: actions/cache@v4 - with: - path: ${{ steps.pip-cache.outputs.dir }} - key: ${{ runner.os }}-${{ matrix.python-version }}-pip-${{ hashFiles('pyproject.toml') }} - restore-keys: | - ${{ runner.os }}-${{ matrix.python-version }}-pip- - - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} diff --git a/pyproject.toml b/pyproject.toml index 08f90c49c..3bf47ea23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,42 +14,42 @@ readme = "README.md" dependencies = [ 'defusedxml>=0.7.1', # latest as at 7/31/23 'packaging>=23.1', # latest as at 7/31/23 - 'requests>=2.32', # latest as at 7/31/23 - 'urllib3>=2.2.2,<3', + 'requests>=2.31', # latest as at 7/31/23 + 'urllib3==2.2.2', # dependabot 'typing_extensions>=4.0.1', ] -requires-python = ">=3.9" +requires-python = ">=3.7" classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13" + "Programming Language :: Python :: 3.12" ] [project.urls] repository = "https://github.com/tableau/server-client-python" [project.optional-dependencies] -test = ["black==24.8", "build", "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] line-length = 120 -target-version = ['py39', 'py310', 'py311', 'py312', 'py313'] +target-version = ['py37', 'py38', 'py39', 'py310', 'py311', 'py312'] [tool.mypy] check_untyped_defs = false disable_error_code = [ 'misc', + # 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", "samples"] +files = ["tableauserverclient", "test"] show_error_codes = true ignore_missing_imports = true # defusedxml library has no types no_implicit_reexport = true -implicit_optional = true [tool.pytest.ini_options] testpaths = ["test"] diff --git a/samples/add_default_permission.py b/samples/add_default_permission.py index d26d009e2..5a450e8ab 100644 --- a/samples/add_default_permission.py +++ b/samples/add_default_permission.py @@ -63,10 +63,10 @@ def main(): for permission in new_default_permissions: grantee = permission.grantee capabilities = permission.capabilities - print(f"\nCapabilities for {grantee.tag_name} {grantee.id}:") + print("\nCapabilities for {0} {1}:".format(grantee.tag_name, grantee.id)) for capability in capabilities: - print(f"\t{capability} - {capabilities[capability]}") + print("\t{0} - {1}".format(capability, capabilities[capability])) # Uncomment lines below to DELETE the new capability and the new project # rules_to_delete = TSC.PermissionsRule( diff --git a/samples/create_group.py b/samples/create_group.py index aca3e895b..f4c6a9ca9 100644 --- a/samples/create_group.py +++ b/samples/create_group.py @@ -11,6 +11,7 @@ import os from datetime import time +from typing import List import tableauserverclient as TSC from tableauserverclient import ServerResponseError @@ -62,23 +63,23 @@ def main(): if args.file: filepath = os.path.abspath(args.file) - print(f"Add users to site from file {filepath}:") - added: list[TSC.UserItem] - failed: list[TSC.UserItem, TSC.ServerResponseError] + print("Add users to site from file {}:".format(filepath)) + added: List[TSC.UserItem] + failed: List[TSC.UserItem, TSC.ServerResponseError] added, failed = server.users.create_from_file(filepath) for user, error in failed: print(user, error.code) if error.code == "409017": user = server.users.filter(name=user.name)[0] added.append(user) - print(f"Adding users to group:{added}") + print("Adding users to group:{}".format(added)) for user in added: - print(f"Adding user {user}") + print("Adding user {}".format(user)) try: server.groups.add_user(group, user.id) except ServerResponseError as serverError: if serverError.code == "409011": - print(f"user {user.name} is already a member of group {group.name}") + print("user {} is already a member of group {}".format(user.name, group.name)) else: raise rError diff --git a/samples/create_project.py b/samples/create_project.py index d775902aa..1fc649f8c 100644 --- a/samples/create_project.py +++ b/samples/create_project.py @@ -84,7 +84,7 @@ def main(): server.projects.populate_datasource_default_permissions(changed_project), server.projects.populate_permissions(changed_project) # Projects have default permissions set for the object types they contain - print(f"Permissions from project {changed_project.id}:") + print("Permissions from project {}:".format(changed_project.id)) print(changed_project.permissions) print( changed_project.default_workbook_permissions, diff --git a/samples/create_schedules.py b/samples/create_schedules.py index c23a2eced..dee088571 100644 --- a/samples/create_schedules.py +++ b/samples/create_schedules.py @@ -55,7 +55,7 @@ def main(): ) try: hourly_schedule = server.schedules.create(hourly_schedule) - print(f"Hourly schedule created (ID: {hourly_schedule.id}).") + print("Hourly schedule created (ID: {}).".format(hourly_schedule.id)) except Exception as e: print(e) @@ -71,7 +71,7 @@ def main(): ) try: daily_schedule = server.schedules.create(daily_schedule) - print(f"Daily schedule created (ID: {daily_schedule.id}).") + print("Daily schedule created (ID: {}).".format(daily_schedule.id)) except Exception as e: print(e) @@ -89,7 +89,7 @@ def main(): ) try: weekly_schedule = server.schedules.create(weekly_schedule) - print(f"Weekly schedule created (ID: {weekly_schedule.id}).") + print("Weekly schedule created (ID: {}).".format(weekly_schedule.id)) except Exception as e: print(e) options = TSC.RequestOptions() @@ -112,7 +112,7 @@ def main(): ) try: monthly_schedule = server.schedules.create(monthly_schedule) - print(f"Monthly schedule created (ID: {monthly_schedule.id}).") + print("Monthly schedule created (ID: {}).".format(monthly_schedule.id)) except Exception as e: print(e) diff --git a/samples/explore_datasource.py b/samples/explore_datasource.py index c9f35d5be..fb45cb45e 100644 --- a/samples/explore_datasource.py +++ b/samples/explore_datasource.py @@ -51,17 +51,16 @@ def main(): if args.publish: if default_project is not None: new_datasource = TSC.DatasourceItem(default_project.id) - new_datasource.description = "Published with a description" new_datasource = server.datasources.publish( new_datasource, args.publish, TSC.Server.PublishMode.Overwrite ) - print(f"Datasource published. ID: {new_datasource.id}") + print("Datasource published. ID: {}".format(new_datasource.id)) else: print("Publish failed. Could not find the default project.") # Gets all datasource items all_datasources, pagination_item = server.datasources.get() - print(f"\nThere are {pagination_item.total_available} datasources on site: ") + print("\nThere are {} datasources on site: ".format(pagination_item.total_available)) print([datasource.name for datasource in all_datasources]) if all_datasources: @@ -70,19 +69,20 @@ def main(): # Populate connections server.datasources.populate_connections(sample_datasource) - print(f"\nConnections for {sample_datasource.name}: ") - print([f"{connection.id}({connection.datasource_name})" for connection in sample_datasource.connections]) - - # Demonstrate that description is editable - sample_datasource.description = "Description updated by TSC" - server.datasources.update(sample_datasource) + print("\nConnections for {}: ".format(sample_datasource.name)) + print( + [ + "{0}({1})".format(connection.id, connection.datasource_name) + for connection in sample_datasource.connections + ] + ) # Add some tags to the datasource original_tag_set = set(sample_datasource.tags) sample_datasource.tags.update("a", "b", "c", "d") server.datasources.update(sample_datasource) - print(f"\nOld tag set: {original_tag_set}") - print(f"New tag set: {sample_datasource.tags}") + print("\nOld tag set: {}".format(original_tag_set)) + print("New tag set: {}".format(sample_datasource.tags)) # Delete all tags that were added by setting tags to original sample_datasource.tags = original_tag_set diff --git a/samples/explore_favorites.py b/samples/explore_favorites.py index f199522ed..243e91954 100644 --- a/samples/explore_favorites.py +++ b/samples/explore_favorites.py @@ -3,7 +3,7 @@ import argparse import logging import tableauserverclient as TSC -from tableauserverclient.models import Resource +from tableauserverclient import Resource def main(): @@ -39,15 +39,15 @@ def main(): # get all favorites on site for the logged on user user: TSC.UserItem = TSC.UserItem() user.id = server.user_id - print(f"Favorites for user: {user.id}") + print("Favorites for user: {}".format(user.id)) server.favorites.get(user) print(user.favorites) # get list of workbooks all_workbook_items, pagination_item = server.workbooks.get() if all_workbook_items is not None and len(all_workbook_items) > 0: - my_workbook = all_workbook_items[0] - server.favorites.add_favorite(user, Resource.Workbook, all_workbook_items[0]) + my_workbook: TSC.WorkbookItem = all_workbook_items[0] + server.favorites.add_favorite(server, user, Resource.Workbook.name(), all_workbook_items[0]) print( "Workbook added to favorites. Workbook Name: {}, Workbook ID: {}".format( my_workbook.name, my_workbook.id @@ -57,7 +57,7 @@ def main(): if views is not None and len(views) > 0: my_view = views[0] server.favorites.add_favorite_view(user, my_view) - print(f"View added to favorites. View Name: {my_view.name}, View ID: {my_view.id}") + print("View added to favorites. View Name: {}, View ID: {}".format(my_view.name, my_view.id)) all_datasource_items, pagination_item = server.datasources.get() if all_datasource_items: @@ -70,10 +70,12 @@ def main(): ) server.favorites.delete_favorite_workbook(user, my_workbook) - print(f"Workbook deleted from favorites. Workbook Name: {my_workbook.name}, Workbook ID: {my_workbook.id}") + print( + "Workbook deleted from favorites. Workbook Name: {}, Workbook ID: {}".format(my_workbook.name, my_workbook.id) + ) server.favorites.delete_favorite_view(user, my_view) - print(f"View deleted from favorites. View Name: {my_view.name}, View ID: {my_view.id}") + print("View deleted from favorites. View Name: {}, View ID: {}".format(my_view.name, my_view.id)) server.favorites.delete_favorite_datasource(user, my_datasource) print( diff --git a/samples/explore_site.py b/samples/explore_site.py index eb9eba0de..a2274f1a7 100644 --- a/samples/explore_site.py +++ b/samples/explore_site.py @@ -49,7 +49,7 @@ def main(): if args.delete: print("You can only delete the site you are currently in") - print(f"Delete site `{current_site.name}`?") + print("Delete site `{}`?".format(current_site.name)) # server.sites.delete(server.site_id) elif args.create: diff --git a/samples/explore_webhooks.py b/samples/explore_webhooks.py index f25c41849..77802b1db 100644 --- a/samples/explore_webhooks.py +++ b/samples/explore_webhooks.py @@ -52,11 +52,11 @@ def main(): new_webhook.event = "datasource-created" print(new_webhook) new_webhook = server.webhooks.create(new_webhook) - print(f"Webhook created. ID: {new_webhook.id}") + print("Webhook created. ID: {}".format(new_webhook.id)) # Gets all webhook items all_webhooks, pagination_item = server.webhooks.get() - print(f"\nThere are {pagination_item.total_available} webhooks on site: ") + print("\nThere are {} webhooks on site: ".format(pagination_item.total_available)) print([webhook.name for webhook in all_webhooks]) if all_webhooks: diff --git a/samples/explore_workbook.py b/samples/explore_workbook.py index f51639ab3..57f88aa07 100644 --- a/samples/explore_workbook.py +++ b/samples/explore_workbook.py @@ -59,13 +59,13 @@ def main(): if default_project is not None: new_workbook = TSC.WorkbookItem(default_project.id) new_workbook = server.workbooks.publish(new_workbook, args.publish, overwrite_true) - print(f"Workbook published. ID: {new_workbook.id}") + print("Workbook published. ID: {}".format(new_workbook.id)) else: print("Publish failed. Could not find the default project.") # Gets all workbook items all_workbooks, pagination_item = server.workbooks.get() - print(f"\nThere are {pagination_item.total_available} workbooks on site: ") + print("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) print([workbook.name for workbook in all_workbooks]) if all_workbooks: @@ -78,22 +78,27 @@ def main(): # Populate views server.workbooks.populate_views(sample_workbook) - print(f"\nName of views in {sample_workbook.name}: ") + print("\nName of views in {}: ".format(sample_workbook.name)) print([view.name for view in sample_workbook.views]) # Populate connections server.workbooks.populate_connections(sample_workbook) - print(f"\nConnections for {sample_workbook.name}: ") - print([f"{connection.id}({connection.datasource_name})" for connection in sample_workbook.connections]) + print("\nConnections for {}: ".format(sample_workbook.name)) + print( + [ + "{0}({1})".format(connection.id, connection.datasource_name) + for connection in sample_workbook.connections + ] + ) # Update tags and show_tabs flag original_tag_set = set(sample_workbook.tags) sample_workbook.tags.update("a", "b", "c", "d") sample_workbook.show_tabs = True server.workbooks.update(sample_workbook) - print(f"\nWorkbook's old tag set: {original_tag_set}") - print(f"Workbook's new tag set: {sample_workbook.tags}") - print(f"Workbook tabbed: {sample_workbook.show_tabs}") + print("\nWorkbook's old tag set: {}".format(original_tag_set)) + print("Workbook's new tag set: {}".format(sample_workbook.tags)) + print("Workbook tabbed: {}".format(sample_workbook.show_tabs)) # Delete all tags that were added by setting tags to original sample_workbook.tags = original_tag_set @@ -104,8 +109,8 @@ def main(): original_tag_set = set(sample_view.tags) sample_view.tags.add("view_tag") server.views.update(sample_view) - print(f"\nView's old tag set: {original_tag_set}") - print(f"View's new tag set: {sample_view.tags}") + print("\nView's old tag set: {}".format(original_tag_set)) + print("View's new tag set: {}".format(sample_view.tags)) # Delete tag from just one view sample_view.tags = original_tag_set @@ -114,14 +119,14 @@ def main(): if args.download: # Download path = server.workbooks.download(sample_workbook.id, args.download) - print(f"\nDownloaded workbook to {path}") + print("\nDownloaded workbook to {}".format(path)) if args.preview_image: # Populate workbook preview image server.workbooks.populate_preview_image(sample_workbook) with open(args.preview_image, "wb") as f: f.write(sample_workbook.preview_image) - print(f"\nDownloaded preview image of workbook to {os.path.abspath(args.preview_image)}") + print("\nDownloaded preview image of workbook to {}".format(os.path.abspath(args.preview_image))) # get custom views cvs, _ = server.custom_views.get() @@ -148,10 +153,10 @@ def main(): server.workbooks.populate_powerpoint(sample_workbook) with open(args.powerpoint, "wb") as f: f.write(sample_workbook.powerpoint) - print(f"\nDownloaded powerpoint of workbook to {os.path.abspath(args.powerpoint)}") + print("\nDownloaded powerpoint of workbook to {}".format(os.path.abspath(args.powerpoint))) if args.delete: - print(f"deleting {c.id}") + print("deleting {}".format(c.id)) unlucky = TSC.CustomViewItem(c.id) server.custom_views.delete(unlucky.id) diff --git a/samples/export.py b/samples/export.py index b2506cf46..f2783fa6e 100644 --- a/samples/export.py +++ b/samples/export.py @@ -37,11 +37,8 @@ def main(): "--csv", dest="type", action="store_const", const=("populate_csv", "CSVRequestOptions", "csv", "csv") ) # other options shown in explore_workbooks: workbook.download, workbook.preview_image - parser.add_argument( - "--language", help="Text such as 'Average' will appear in this language. Use values like fr, de, es, en" - ) + parser.add_argument("--workbook", action="store_true") - parser.add_argument("--custom_view", action="store_true") parser.add_argument("--file", "-f", help="filename to store the exported data") parser.add_argument("--filter", "-vf", metavar="COLUMN:VALUE", help="View filter to apply to the view") @@ -59,16 +56,14 @@ def main(): print("Connected") if args.workbook: item = server.workbooks.get_by_id(args.resource_id) - elif args.custom_view: - item = server.custom_views.get_by_id(args.resource_id) else: item = server.views.get_by_id(args.resource_id) if not item: - print(f"No item found for id {args.resource_id}") + print("No item found for id {}".format(args.resource_id)) exit(1) - print(f"Item found: {item.name}") + print("Item found: {}".format(item.name)) # We have a number of different types and functions for each different export type. # We encode that information above in the const=(...) parameter to the add_argument function to make # the code automatically adapt for the type of export the user is doing. @@ -77,22 +72,18 @@ def main(): populate = getattr(server.views, populate_func_name) if args.workbook: populate = getattr(server.workbooks, populate_func_name) - elif args.custom_view: - populate = getattr(server.custom_views, populate_func_name) option_factory = getattr(TSC, option_factory_name) - options: TSC.PDFRequestOptions = option_factory() if args.filter: - options = options.vf(*args.filter.split(":")) - - if args.language: - options.language = args.language + options = option_factory().vf(*args.filter.split(":")) + else: + options = None if args.file: filename = args.file else: - filename = f"out-{options.language}.{extension}" + filename = "out.{}".format(extension) populate(item, options) with open(filename, "wb") as f: diff --git a/samples/extracts.py b/samples/extracts.py index c0dd885bc..9bd87a473 100644 --- a/samples/extracts.py +++ b/samples/extracts.py @@ -1,7 +1,13 @@ #### -# This script demonstrates how to use the Tableau Server Client to interact with extracts. -# It explores the different functions that the REST API supports on extracts. -##### +# This script demonstrates how to use the Tableau Server Client +# to interact with workbooks. It explores the different +# functions that the Server API supports on workbooks. +# +# With no flags set, this sample will query all workbooks, +# pick one workbook and populate its connections/views, and update +# the workbook. Adding flags will demonstrate the specific feature +# on top of the general operations. +#### import argparse import logging @@ -41,7 +47,7 @@ def main(): with server.auth.sign_in(tableau_auth): # Gets all workbook items all_workbooks, pagination_item = server.workbooks.get() - print(f"\nThere are {pagination_item.total_available} workbooks on site: ") + print("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) print([workbook.name for workbook in all_workbooks]) if all_workbooks: diff --git a/samples/filter_sort_groups.py b/samples/filter_sort_groups.py index 1694bf0f5..042af32e2 100644 --- a/samples/filter_sort_groups.py +++ b/samples/filter_sort_groups.py @@ -47,7 +47,7 @@ def main(): 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=True, http_options={"verify": False}) + server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): group_name = "SALES NORTHWEST" # Try to create a group named "SALES NORTHWEST" @@ -57,36 +57,37 @@ def main(): # Try to create a group named "SALES ROMANIA" create_example_group(group_name, server) - # we no longer need to encode the space + # URL Encode the name of the group that we want to filter on + # i.e. turn spaces into plus signs + filter_group_name = urllib.parse.quote_plus(group_name) options = TSC.RequestOptions() - options.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, group_name)) + options.filter.add( + TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, filter_group_name) + ) filtered_groups, _ = server.groups.get(req_options=options) # Result can either be a matching group or an empty list if filtered_groups: - group = filtered_groups.pop() - print(group) + group_name = filtered_groups.pop().name + print(group_name) else: - error = f"No group named '{group_name}' found" + error = "No project named '{}' found".format(filter_group_name) print(error) - print("---") - # Or, try the above with the django style filtering try: - group = server.groups.filter(name=group_name)[0] - print(group) + group = server.groups.filter(name=filter_group_name)[0] except IndexError: - print(f"No group named '{group_name}' found") - - print("====") + print(f"No project named '{filter_group_name}' found") + else: + print(group.name) options = TSC.RequestOptions() options.filter.add( TSC.Filter( TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.In, - ["SALES NORTHWEST", "SALES ROMANIA", "this_group"], + ["SALES+NORTHWEST", "SALES+ROMANIA", "this_group"], ) ) @@ -97,19 +98,12 @@ def main(): for group in matching_groups: print(group.name) - print("----") # or, try the above with the django style filtering. - all_g = server.groups.all() - print(f"Searching locally among {all_g.total_available} groups") - for a in all_g: - print(a) - groups = [urllib.parse.quote_plus(group) for group in ["SALES NORTHWEST", "SALES ROMANIA", "this_group"]] - print(groups) - - for group in server.groups.filter(name__in=groups).order_by("-name"): - print(group.name) - print("done") + groups = ["SALES NORTHWEST", "SALES ROMANIA", "this_group"] + groups = [urllib.parse.quote_plus(group) for group in groups] + for group in server.groups.filter(name__in=groups).sort("-name"): + print(group.name) if __name__ == "__main__": diff --git a/samples/filter_sort_projects.py b/samples/filter_sort_projects.py index 6c3a85dcd..7aa62a5c1 100644 --- a/samples/filter_sort_projects.py +++ b/samples/filter_sort_projects.py @@ -68,7 +68,7 @@ def main(): project_name = filtered_projects.pop().name print(project_name) else: - error = f"No project named '{filter_project_name}' found" + error = "No project named '{}' found".format(filter_project_name) print(error) create_example_project(name="Example 1", server=server) diff --git a/samples/getting_started/1_hello_server.py b/samples/getting_started/1_hello_server.py index 5f8cfa238..454b225de 100644 --- a/samples/getting_started/1_hello_server.py +++ b/samples/getting_started/1_hello_server.py @@ -12,8 +12,8 @@ def main(): # This is the domain for Tableau's Developer Program server_url = "https://10ax.online.tableau.com" server = TSC.Server(server_url) - print(f"Connected to {server.server_info.baseurl}") - print(f"Server information: {server.server_info}") + 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") diff --git a/samples/getting_started/2_hello_site.py b/samples/getting_started/2_hello_site.py index 8635947a8..d62896059 100644 --- a/samples/getting_started/2_hello_site.py +++ b/samples/getting_started/2_hello_site.py @@ -19,7 +19,7 @@ def main(): use_ssl = True server = TSC.Server(server_url, use_server_version=True, http_options={"verify": use_ssl}) - print(f"Connected to {server.server_info.baseurl}") + 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 @@ -39,7 +39,7 @@ def main(): with server.auth.sign_in(tableau_auth): projects, pagination = server.projects.get() if projects: - print(f"{pagination.total_available} projects") + print("{} projects".format(pagination.total_available)) project = projects[0] print(project.name) diff --git a/samples/getting_started/3_hello_universe.py b/samples/getting_started/3_hello_universe.py index a2c4301d0..21de97831 100644 --- a/samples/getting_started/3_hello_universe.py +++ b/samples/getting_started/3_hello_universe.py @@ -17,7 +17,7 @@ def main(): use_ssl = True server = TSC.Server(server_url, use_server_version=True, http_options={"verify": use_ssl}) - print(f"Connected to {server.server_info.baseurl}") + 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/ @@ -36,55 +36,55 @@ def main(): with server.auth.sign_in(tableau_auth): projects, pagination = server.projects.get() if projects: - print(f"{pagination.total_available} projects") + print("{} projects".format(pagination.total_available)) for project in projects: print(project.name) workbooks, pagination = server.datasources.get() if workbooks: - print(f"{pagination.total_available} workbooks") + print("{} workbooks".format(pagination.total_available)) print(workbooks[0]) views, pagination = server.views.get() if views: - print(f"{pagination.total_available} views") + print("{} views".format(pagination.total_available)) print(views[0]) datasources, pagination = server.datasources.get() if datasources: - print(f"{pagination.total_available} 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(f"{pagination.total_available} jobs") + print("{} jobs".format(pagination.total_available)) print(jobs[0]) schedules, pagination = server.schedules.get() if schedules: - print(f"{pagination.total_available} schedules") + print("{} schedules".format(pagination.total_available)) print(schedules[0]) tasks, pagination = server.tasks.get() if tasks: - print(f"{pagination.total_available} tasks") + print("{} tasks".format(pagination.total_available)) print(tasks[0]) webhooks, pagination = server.webhooks.get() if webhooks: - print(f"{pagination.total_available} webhooks") + print("{} webhooks".format(pagination.total_available)) print(webhooks[0]) users, pagination = server.users.get() if users: - print(f"{pagination.total_available} users") + print("{} users".format(pagination.total_available)) print(users[0]) groups, pagination = server.groups.get() if groups: - print(f"{pagination.total_available} groups") + print("{} groups".format(pagination.total_available)) print(groups[0]) diff --git a/samples/initialize_server.py b/samples/initialize_server.py index cdfaf27a8..cb3d9e1d0 100644 --- a/samples/initialize_server.py +++ b/samples/initialize_server.py @@ -51,7 +51,7 @@ def main(): # Create the site if it doesn't exist if existing_site is None: - print(f"Site not found: {args.site_id} Creating it...") + print("Site not found: {0} Creating it...".format(args.site_id)) new_site = TSC.SiteItem( name=args.site_id, content_url=args.site_id.replace(" ", ""), @@ -59,7 +59,7 @@ def main(): ) server.sites.create(new_site) else: - print(f"Site {args.site_id} exists. Moving on...") + print("Site {0} exists. Moving on...".format(args.site_id)) ################################################################################ # Step 3: Sign-in to our target site @@ -81,7 +81,7 @@ def main(): # Create our project if it doesn't exist if project is None: - print(f"Project not found: {args.project} Creating it...") + print("Project not found: {0} Creating it...".format(args.project)) new_project = TSC.ProjectItem(name=args.project) project = server_upload.projects.create(new_project) @@ -100,7 +100,7 @@ def publish_datasources_to_site(server_object, project, folder): for fname in glob.glob(path): new_ds = TSC.DatasourceItem(project.id) new_ds = server_object.datasources.publish(new_ds, fname, server_object.PublishMode.Overwrite) - print(f"Datasource published. ID: {new_ds.id}") + print("Datasource published. ID: {0}".format(new_ds.id)) def publish_workbooks_to_site(server_object, project, folder): @@ -110,7 +110,7 @@ def publish_workbooks_to_site(server_object, project, folder): new_workbook = TSC.WorkbookItem(project.id) new_workbook.show_tabs = True new_workbook = server_object.workbooks.publish(new_workbook, fname, server_object.PublishMode.Overwrite) - print(f"Workbook published. ID: {new_workbook.id}") + print("Workbook published. ID: {0}".format(new_workbook.id)) if __name__ == "__main__": diff --git a/samples/list.py b/samples/list.py index 2675a2954..8d72fb620 100644 --- a/samples/list.py +++ b/samples/list.py @@ -48,9 +48,6 @@ def main(): "webhooks": server.webhooks, "workbook": server.workbooks, }.get(args.resource_type) - if endpoint is None: - print("Resource type not found.") - sys.exit(1) options = TSC.RequestOptions() options.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Direction.Desc)) @@ -62,7 +59,7 @@ def main(): print(resource.name[:18], " ") # , resource._connections()) if count > 100: break - print(f"Total: {count}") + print("Total: {}".format(count)) if __name__ == "__main__": diff --git a/samples/login.py b/samples/login.py index bc99385b3..6a3e9e8b3 100644 --- a/samples/login.py +++ b/samples/login.py @@ -7,15 +7,9 @@ import argparse import getpass import logging -import os import tableauserverclient as TSC - - -def get_env(key): - if key in os.environ: - return os.environ[key] - return None +import env # If a sample has additional arguments, then it should copy this code and insert them after the call to @@ -26,13 +20,13 @@ def set_up_and_log_in(): sample_define_common_options(parser) args = parser.parse_args() if not args.server: - args.server = get_env("SERVER") + args.server = env.server if not args.site: - args.site = get_env("SITE") + args.site = env.site if not args.token_name: - args.token_name = get_env("TOKEN_NAME") + args.token_name = env.token_name if not args.token_value: - args.token_value = get_env("TOKEN_VALUE") + args.token_value = env.token_value args.logging_level = "debug" server = sample_connect_to_server(args) @@ -65,7 +59,7 @@ def sample_connect_to_server(args): password = args.password or getpass.getpass("Password: ") tableau_auth = TSC.TableauAuth(args.username, password, site_id=args.site) - print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\nUsername: {args.username}") + print("\nSigning in...\nServer: {}\nSite: {}\nUsername: {}".format(args.server, args.site, args.username)) else: # Trying to authenticate using personal access tokens. @@ -74,7 +68,7 @@ def sample_connect_to_server(args): tableau_auth = TSC.PersonalAccessTokenAuth( token_name=args.token_name, personal_access_token=token, site_id=args.site ) - print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\nToken name: {args.token_name}") + print("\nSigning in...\nServer: {}\nSite: {}\nToken name: {}".format(args.server, args.site, args.token_name)) if not tableau_auth: raise TabError("Did not create authentication object. Check arguments.") @@ -85,7 +79,10 @@ def sample_connect_to_server(args): # Make sure we use an updated version of the rest apis, and pass in our cert handling choice server = TSC.Server(args.server, use_server_version=True, http_options={"verify": check_ssl_certificate}) server.auth.sign_in(tableau_auth) - server.version = "3.19" + server.version = "2.6" + new_site: TSC.SiteItem = TSC.SiteItem("cdnear", content_url=env.site) + server.auth.switch_site(new_site) + print("Logged in successfully") return server diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index e82c75cf9..47af1f2f9 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -59,7 +59,7 @@ def main(): # Step 3: Download workbook to a temp directory if len(all_workbooks) == 0: - print(f"No workbook named {args.workbook_name} found.") + print("No workbook named {} found.".format(args.workbook_name)) else: tmpdir = tempfile.mkdtemp() try: @@ -68,10 +68,10 @@ def main(): # Step 4: Check if destination site exists, then sign in to the site all_sites, pagination_info = source_server.sites.get() found_destination_site = any( - True for site in all_sites if args.destination_site.lower() == site.content_url.lower() + (True for site in all_sites if args.destination_site.lower() == site.content_url.lower()) ) if not found_destination_site: - error = f"No site named {args.destination_site} found." + error = "No site named {} found.".format(args.destination_site) raise LookupError(error) tableau_auth.site_id = args.destination_site @@ -85,7 +85,7 @@ def main(): new_workbook = dest_server.workbooks.publish( new_workbook, workbook_path, mode=TSC.Server.PublishMode.Overwrite ) - print(f"Successfully moved {new_workbook.name} ({new_workbook.id})") + print("Successfully moved {0} ({1})".format(new_workbook.name, new_workbook.id)) # Step 6: Delete workbook from source site and delete temp directory source_server.workbooks.delete(all_workbooks[0].id) diff --git a/samples/pagination_sample.py b/samples/pagination_sample.py index a68eed4b3..a7ae6dc89 100644 --- a/samples/pagination_sample.py +++ b/samples/pagination_sample.py @@ -57,7 +57,7 @@ def main(): for wb in TSC.Pager(server.workbooks, page_options): print(wb.name) count = count + 1 - print(f"Total: {count}\n") + print("Total: {}\n".format(count)) count = 0 page_options = TSC.RequestOptions(2, 3) @@ -65,7 +65,7 @@ def main(): for wb in TSC.Pager(server.workbooks, page_options): print(wb.name) count = count + 1 - print(f"Truncated Total: {count}\n") + print("Truncated Total: {}\n".format(count)) print("Your id: ", you.name, you.id, "\n") count = 0 @@ -76,7 +76,7 @@ def main(): for wb in TSC.Pager(server.workbooks, filtered_page_options): print(wb.name, " -- ", wb.owner_id) count = count + 1 - print(f"Filtered Total: {count}\n") + print("Filtered Total: {}\n".format(count)) # 2. QuerySet offers a fluent interface on top of the RequestOptions object print("Fetching workbooks again - this time filtered with QuerySet") @@ -90,7 +90,7 @@ def main(): count = count + 1 more = queryset.total_available > count page = page + 1 - print(f"QuerySet Total: {count}") + print("QuerySet Total: {}".format(count)) # 3. QuerySet also allows you to iterate over all objects without explicitly paging. print("Fetching again - this time without manually paging") diff --git a/samples/publish_datasource.py b/samples/publish_datasource.py index c674e6882..5ac768674 100644 --- a/samples/publish_datasource.py +++ b/samples/publish_datasource.py @@ -21,15 +21,10 @@ import argparse import logging -import os import tableauserverclient as TSC -import tableauserverclient.datetime_helpers - -def get_env(key): - if key in os.environ: - return os.environ[key] - return None +import env +import tableauserverclient.datetime_helpers def main(): @@ -57,13 +52,13 @@ def main(): args = parser.parse_args() if not args.server: - args.server = get_env("SERVER") + args.server = env.server if not args.site: - args.site = get_env("SITE") + args.site = env.site if not args.token_name: - args.token_name = get_env("TOKEN_NAME") + args.token_name = env.token_name if not args.token_value: - args.token_value = get_env("TOKEN_VALUE") + args.token_value = env.token_value args.logging = "debug" args.file = "C:/dev/tab-samples/5M.tdsx" args.async_ = True @@ -116,17 +111,15 @@ def main(): new_job = server.datasources.publish( new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds, as_job=True ) - print(f"Datasource published asynchronously. Job ID: {new_job.id}") + print("Datasource published asynchronously. Job ID: {0}".format(new_job.id)) else: # Normal publishing, returns a datasource_item new_datasource = server.datasources.publish( new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds ) print( - ( - "{}Datasource published. Datasource ID: {}".format( - new_datasource.id, tableauserverclient.datetime_helpers.timestamp() - ) + "{0}Datasource published. Datasource ID: {1}".format( + new_datasource.id, tableauserverclient.datetime_helpers.timestamp() ) ) print("\t\tClosing connection") diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index d31978c0f..8a9f45279 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -80,7 +80,7 @@ def main(): as_job=args.as_job, skip_connection_check=args.skip_connection_check, ) - print(f"Workbook published. JOB ID: {new_job.id}") + print("Workbook published. JOB ID: {0}".format(new_job.id)) else: new_workbook = server.workbooks.publish( new_workbook, @@ -90,7 +90,7 @@ def main(): as_job=args.as_job, skip_connection_check=args.skip_connection_check, ) - print(f"Workbook published. ID: {new_workbook.id}") + print("Workbook published. ID: {0}".format(new_workbook.id)) else: error = "The default project could not be found." raise LookupError(error) diff --git a/samples/query_permissions.py b/samples/query_permissions.py index 3309acd90..4e509cd97 100644 --- a/samples/query_permissions.py +++ b/samples/query_permissions.py @@ -57,15 +57,17 @@ def main(): permissions = resource.permissions # Print result - print(f"\n{len(permissions)} permission rule(s) found for {args.resource_type} {args.resource_id}.") + print( + "\n{0} permission rule(s) found for {1} {2}.".format(len(permissions), args.resource_type, args.resource_id) + ) for permission in permissions: grantee = permission.grantee capabilities = permission.capabilities - print(f"\nCapabilities for {grantee.tag_name} {grantee.id}:") + print("\nCapabilities for {0} {1}:".format(grantee.tag_name, grantee.id)) for capability in capabilities: - print(f"\t{capability} - {capabilities[capability]}") + print("\t{0} - {1}".format(capability, capabilities[capability])) if __name__ == "__main__": diff --git a/samples/refresh_tasks.py b/samples/refresh_tasks.py index c95000898..03daedf16 100644 --- a/samples/refresh_tasks.py +++ b/samples/refresh_tasks.py @@ -19,12 +19,12 @@ def handle_run(server, args): def handle_list(server, _): tasks, pagination = server.tasks.get() for task in tasks: - print(f"{task}") + print("{}".format(task)) def handle_info(server, args): task = server.tasks.get_by_id(args.id) - print(f"{task}") + print("{}".format(task)) def main(): diff --git a/samples/set_refresh_schedule.py b/samples/set_refresh_schedule.py index 153bb0ee5..56fd12e62 100644 --- a/samples/set_refresh_schedule.py +++ b/samples/set_refresh_schedule.py @@ -38,7 +38,7 @@ def usage(args): def make_filter(**kwargs): options = TSC.RequestOptions() - for item, value in list(kwargs.items()): + for item, value in kwargs.items(): name = getattr(TSC.RequestOptions.Field, item) options.filter.add(TSC.Filter(name, TSC.RequestOptions.Operator.Equals, value)) return options diff --git a/samples/update_connection.py b/samples/update_connection.py index 0fe2f342c..4af6592bc 100644 --- a/samples/update_connection.py +++ b/samples/update_connection.py @@ -45,7 +45,7 @@ def main(): update_function = endpoint.update_connection resource = endpoint.get_by_id(args.resource_id) endpoint.populate_connections(resource) - connections = list([x for x in resource.connections if x.id == args.connection_id]) + connections = list(filter(lambda x: x.id == args.connection_id, resource.connections)) assert len(connections) == 1 connection = connections[0] connection.username = args.datasource_username 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 index c23e3717f..9e4d63dc1 100644 --- a/samples/update_workbook_data_freshness_policy.py +++ b/samples/update_workbook_data_freshness_policy.py @@ -45,7 +45,7 @@ def main(): with server.auth.sign_in(tableau_auth): # Get workbook all_workbooks, pagination_item = server.workbooks.get() - print(f"\nThere are {pagination_item.total_available} workbooks on site: ") + print("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) print([workbook.name for workbook in all_workbooks]) if all_workbooks: diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index e0a7abb64..bab2cf05f 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -32,13 +32,11 @@ PermissionsRule, PersonalAccessTokenAuth, ProjectItem, - Resource, RevisionItem, ScheduleItem, SiteItem, ServerInfoItem, SubscriptionItem, - TableauItem, TableItem, TableauAuth, Target, @@ -58,7 +56,6 @@ PDFRequestOptions, RequestOptions, MissingRequiredFieldError, - FailedSignInError, NotSignedInError, ServerResponseError, Filter, @@ -68,68 +65,65 @@ ) __all__ = [ + "get_versions", + "DEFAULT_NAMESPACE", "BackgroundJobItem", "BackgroundJobItem", "ColumnItem", "ConnectionCredentials", "ConnectionItem", - "CSVRequestOptions", "CustomViewItem", + "DQWItem", "DailyInterval", "DataAlertItem", "DatabaseItem", "DataFreshnessPolicyItem", "DatasourceItem", - "DEFAULT_NAMESPACE", - "DQWItem", - "ExcelRequestOptions", - "FailedSignInError", "FavoriteItem", - "FileuploadItem", - "Filter", "FlowItem", "FlowRunItem", - "get_versions", + "FileuploadItem", "GroupItem", "GroupSetItem", "HourlyInterval", - "ImageRequestOptions", "IntervalItem", "JobItem", "JWTAuth", - "LinkedTaskFlowRunItem", - "LinkedTaskItem", - "LinkedTaskStepItem", "MetricItem", - "MissingRequiredFieldError", "MonthlyInterval", - "NotSignedInError", - "Pager", "PaginationItem", - "PDFRequestOptions", "Permission", "PermissionsRule", "PersonalAccessTokenAuth", "ProjectItem", - "RequestOptions", - "Resource", "RevisionItem", "ScheduleItem", - "Server", - "ServerInfoItem", - "ServerResponseError", "SiteItem", - "Sort", + "ServerInfoItem", "SubscriptionItem", - "TableauAuth", - "TableauItem", "TableItem", + "TableauAuth", "Target", "TaskItem", "UserItem", "ViewItem", - "VirtualConnectionItem", "WebhookItem", "WeeklyInterval", "WorkbookItem", + "CSVRequestOptions", + "ExcelRequestOptions", + "ImageRequestOptions", + "PDFRequestOptions", + "RequestOptions", + "MissingRequiredFieldError", + "NotSignedInError", + "ServerResponseError", + "Filter", + "Pager", + "Server", + "Sort", + "LinkedTaskItem", + "LinkedTaskStepItem", + "LinkedTaskFlowRunItem", + "VirtualConnectionItem", ] diff --git a/tableauserverclient/_version.py b/tableauserverclient/_version.py index 79dbed1d8..d47374097 100644 --- a/tableauserverclient/_version.py +++ b/tableauserverclient/_version.py @@ -84,7 +84,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= stderr=(subprocess.PIPE if hide_stderr else None), ) break - except OSError: + except EnvironmentError: e = sys.exc_info()[1] if e.errno == errno.ENOENT: continue @@ -94,7 +94,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= return None, None else: if verbose: - print(f"unable to find command, tried {commands}") + print("unable to find command, tried %s" % (commands,)) return None, None stdout = p.communicate()[0].strip() if sys.version_info[0] >= 3: @@ -131,7 +131,7 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): root = os.path.dirname(root) # up a level if verbose: - print(f"Tried directories {str(rootdirs)} but none started with prefix {parentdir_prefix}") + print("Tried directories %s but none started with prefix %s" % (str(rootdirs), parentdir_prefix)) raise NotThisMethod("rootdir doesn't start with parentdir_prefix") @@ -144,7 +144,7 @@ def git_get_keywords(versionfile_abs): # _version.py. keywords = {} try: - f = open(versionfile_abs) + f = open(versionfile_abs, "r") for line in f.readlines(): if line.strip().startswith("git_refnames ="): mo = re.search(r'=\s*"(.*)"', line) @@ -159,7 +159,7 @@ def git_get_keywords(versionfile_abs): if mo: keywords["date"] = mo.group(1) f.close() - except OSError: + except EnvironmentError: pass return keywords @@ -183,11 +183,11 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): if verbose: print("keywords are unexpanded, not using") raise NotThisMethod("unexpanded keywords, not a git-archive tarball") - refs = {r.strip() for r in refnames.strip("()").split(",")} + refs = set([r.strip() for r in refnames.strip("()").split(",")]) # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. TAG = "tag: " - tags = {r[len(TAG) :] for r in refs if r.startswith(TAG)} + tags = set([r[len(TAG) :] for r in refs if r.startswith(TAG)]) if not tags: # Either we're using git < 1.8.3, or there really are no tags. We use # a heuristic: assume all version tags have a digit. The old git %d @@ -196,7 +196,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # between branches and tags. By ignoring refnames without digits, we # filter out many common branch names like "release" and # "stabilization", as well as "HEAD" and "master". - tags = {r for r in refs if re.search(r"\d", r)} + tags = set([r for r in refs if re.search(r"\d", r)]) if verbose: print("discarding '%s', no digits" % ",".join(refs - tags)) if verbose: @@ -299,7 +299,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if verbose: fmt = "tag '%s' doesn't start with prefix '%s'" print(fmt % (full_tag, tag_prefix)) - pieces["error"] = "tag '{}' doesn't start with prefix '{}'".format( + pieces["error"] = "tag '%s' doesn't start with prefix '%s'" % ( full_tag, tag_prefix, ) diff --git a/tableauserverclient/config.py b/tableauserverclient/config.py index a75112754..63872398f 100644 --- a/tableauserverclient/config.py +++ b/tableauserverclient/config.py @@ -6,13 +6,11 @@ DELAY_SLEEP_SECONDS = 0.1 +# The maximum size of a file that can be published in a single request is 64MB +FILESIZE_LIMIT_MB = 64 -class Config: - # The maximum size of a file that can be published in a single request is 64MB - @property - def FILESIZE_LIMIT_MB(self): - return min(int(os.getenv("TSC_FILESIZE_LIMIT_MB", 64)), 64) +class Config: # For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks @property def CHUNK_SIZE_MB(self): diff --git a/tableauserverclient/models/column_item.py b/tableauserverclient/models/column_item.py index 3a7416e28..df936e315 100644 --- a/tableauserverclient/models/column_item.py +++ b/tableauserverclient/models/column_item.py @@ -3,7 +3,7 @@ from .property_decorators import property_not_empty -class ColumnItem: +class ColumnItem(object): def __init__(self, name, description=None): self._id = None self.description = description diff --git a/tableauserverclient/models/connection_credentials.py b/tableauserverclient/models/connection_credentials.py index bb2cbbba9..d61bbb751 100644 --- a/tableauserverclient/models/connection_credentials.py +++ b/tableauserverclient/models/connection_credentials.py @@ -1,7 +1,7 @@ from .property_decorators import property_is_boolean -class ConnectionCredentials: +class ConnectionCredentials(object): """Connection Credentials for Workbooks and Datasources publish request. Consider removing this object and other variables holding secrets diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 937e43481..62ff530c9 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -1,5 +1,5 @@ import logging -from typing import Optional +from typing import List, Optional from defusedxml.ElementTree import fromstring @@ -8,7 +8,7 @@ from tableauserverclient.helpers.logging import logger -class ConnectionItem: +class ConnectionItem(object): def __init__(self): self._datasource_id: Optional[str] = None self._datasource_name: Optional[str] = None @@ -48,7 +48,7 @@ def query_tagging(self, value: Optional[bool]): # if connection type = hyper, Snowflake, or Teradata, we can't change this value: it is always true if self._connection_type in ["hyper", "snowflake", "teradata"]: logger.debug( - f"Cannot update value: Query tagging is always enabled for {self._connection_type} connections" + "Cannot update value: Query tagging is always enabled for {} connections".format(self._connection_type) ) return self._query_tagging = value @@ -59,7 +59,7 @@ def __repr__(self): ) @classmethod - def from_response(cls, resp, ns) -> list["ConnectionItem"]: + def from_response(cls, resp, ns) -> List["ConnectionItem"]: all_connection_items = list() parsed_response = fromstring(resp) all_connection_xml = parsed_response.findall(".//t:connection", namespaces=ns) @@ -82,7 +82,7 @@ def from_response(cls, resp, ns) -> list["ConnectionItem"]: return all_connection_items @classmethod - def from_xml_element(cls, parsed_response, ns) -> list["ConnectionItem"]: + def from_xml_element(cls, parsed_response, ns) -> List["ConnectionItem"]: """ @@ -93,7 +93,7 @@ def from_xml_element(cls, parsed_response, ns) -> list["ConnectionItem"]: """ - all_connection_items: list["ConnectionItem"] = list() + all_connection_items: List["ConnectionItem"] = list() all_connection_xml = parsed_response.findall(".//t:connection", namespaces=ns) for connection_xml in all_connection_xml: diff --git a/tableauserverclient/models/custom_view_item.py b/tableauserverclient/models/custom_view_item.py index a0c0a9844..246a19e7f 100644 --- a/tableauserverclient/models/custom_view_item.py +++ b/tableauserverclient/models/custom_view_item.py @@ -2,8 +2,7 @@ from defusedxml import ElementTree from defusedxml.ElementTree import fromstring, tostring -from typing import Callable, Optional -from collections.abc import Iterator +from typing import Callable, List, Optional from .exceptions import UnpopulatedPropertyError from .user_item import UserItem @@ -12,14 +11,12 @@ from ..datetime_helpers import parse_datetime -class CustomViewItem: +class CustomViewItem(object): def __init__(self, id: Optional[str] = None, name: Optional[str] = None) -> None: self._content_url: Optional[str] = None # ? self._created_at: Optional["datetime"] = None self._id: Optional[str] = id self._image: Optional[Callable[[], bytes]] = None - self._pdf: Optional[Callable[[], bytes]] = None - self._csv: Optional[Callable[[], Iterator[bytes]]] = None self._name: Optional[str] = name self._shared: Optional[bool] = False self._updated_at: Optional["datetime"] = None @@ -38,17 +35,11 @@ def __repr__(self: "CustomViewItem"): owner_info = "" if self._owner: owner_info = " owner='{}'".format(self._owner.name or self._owner.id or "unknown") - return f"" + return "".format(self.id, self.name, view_info, wb_info, owner_info) def _set_image(self, image): self._image = image - def _set_pdf(self, pdf): - self._pdf = pdf - - def _set_csv(self, csv): - self._csv = csv - @property def content_url(self) -> Optional[str]: return self._content_url @@ -64,24 +55,10 @@ def id(self) -> Optional[str]: @property def image(self) -> bytes: if self._image is None: - error = "Custom View item must be populated with its png image first." + error = "View item must be populated with its png image first." raise UnpopulatedPropertyError(error) return self._image() - @property - def pdf(self) -> bytes: - if self._pdf is None: - error = "Custom View item must be populated with its pdf first." - raise UnpopulatedPropertyError(error) - return self._pdf() - - @property - def csv(self) -> Iterator[bytes]: - if self._csv is None: - error = "Custom View item must be populated with its csv first." - raise UnpopulatedPropertyError(error) - return self._csv() - @property def name(self) -> Optional[str]: return self._name @@ -127,7 +104,7 @@ def from_response(cls, resp, ns, workbook_id="") -> Optional["CustomViewItem"]: return item[0] @classmethod - def list_from_response(cls, resp, ns, workbook_id="") -> list["CustomViewItem"]: + def list_from_response(cls, resp, ns, workbook_id="") -> List["CustomViewItem"]: return cls.from_xml_element(fromstring(resp), ns, workbook_id) """ @@ -144,7 +121,7 @@ def list_from_response(cls, resp, ns, workbook_id="") -> list["CustomViewItem"]: """ @classmethod - def from_xml_element(cls, parsed_response, ns, workbook_id="") -> list["CustomViewItem"]: + def from_xml_element(cls, parsed_response, ns, workbook_id="") -> List["CustomViewItem"]: all_view_items = list() all_view_xml = parsed_response.findall(".//t:customView", namespaces=ns) for custom_view_xml in all_view_xml: diff --git a/tableauserverclient/models/data_acceleration_report_item.py b/tableauserverclient/models/data_acceleration_report_item.py index 3a8883bed..7424e6b95 100644 --- a/tableauserverclient/models/data_acceleration_report_item.py +++ b/tableauserverclient/models/data_acceleration_report_item.py @@ -1,8 +1,8 @@ from defusedxml.ElementTree import fromstring -class DataAccelerationReportItem: - class ComparisonRecord: +class DataAccelerationReportItem(object): + class ComparisonRecord(object): def __init__( self, site, diff --git a/tableauserverclient/models/data_alert_item.py b/tableauserverclient/models/data_alert_item.py index 7285ee609..65be233e3 100644 --- a/tableauserverclient/models/data_alert_item.py +++ b/tableauserverclient/models/data_alert_item.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Optional +from typing import List, Optional from defusedxml.ElementTree import fromstring @@ -10,7 +10,7 @@ ) -class DataAlertItem: +class DataAlertItem(object): class Frequency: Once = "Once" Frequently = "Frequently" @@ -34,7 +34,7 @@ def __init__(self): self._workbook_name: Optional[str] = None self._project_id: Optional[str] = None self._project_name: Optional[str] = None - self._recipients: Optional[list[str]] = None + self._recipients: Optional[List[str]] = None def __repr__(self) -> str: return " Optional[str]: return self._creatorId @property - def recipients(self) -> list[str]: + def recipients(self) -> List[str]: return self._recipients or list() @property @@ -174,7 +174,7 @@ def _set_values( self._recipients = recipients @classmethod - def from_response(cls, resp, ns) -> list["DataAlertItem"]: + def from_response(cls, resp, ns) -> List["DataAlertItem"]: all_alert_items = list() parsed_response = fromstring(resp) all_alert_xml = parsed_response.findall(".//t:dataAlert", namespaces=ns) diff --git a/tableauserverclient/models/data_freshness_policy_item.py b/tableauserverclient/models/data_freshness_policy_item.py index 6e0cb9001..f567c501c 100644 --- a/tableauserverclient/models/data_freshness_policy_item.py +++ b/tableauserverclient/models/data_freshness_policy_item.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as ET -from typing import Optional +from typing import Optional, Union, List from tableauserverclient.models.property_decorators import property_is_enum, property_not_nullable from .interval_item import IntervalItem @@ -50,11 +50,11 @@ class Frequency: Week = "Week" Month = "Month" - def __init__(self, frequency: str, time: str, timezone, interval_item: Optional[list[str]] = None): + 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 + self.interval_item: Optional[List[str]] = interval_item def __repr__(self): return ( @@ -62,11 +62,11 @@ def __repr__(self): ).format(**vars(self)) @property - def interval_item(self) -> Optional[list[str]]: + def interval_item(self) -> Optional[List[str]]: return self._interval_item @interval_item.setter - def interval_item(self, value: list[str]): + def interval_item(self, value: List[str]): self._interval_item = value @property @@ -186,7 +186,7 @@ def parse_week_intervals(interval_values): def parse_month_intervals(interval_values): - error = f"Invalid interval value for a monthly frequency: {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 diff --git a/tableauserverclient/models/database_item.py b/tableauserverclient/models/database_item.py index 4d4604461..dfc58e1bb 100644 --- a/tableauserverclient/models/database_item.py +++ b/tableauserverclient/models/database_item.py @@ -10,7 +10,7 @@ ) -class DatabaseItem: +class DatabaseItem(object): class ContentPermissions: LockedToProject = "LockedToDatabase" ManagedByOwner = "ManagedByOwner" @@ -45,7 +45,7 @@ def __init__(self, name, description=None, content_permissions=None): self._tables = None # Not implemented yet def __str__(self): - return f"" + return "".format(self._id, self.name) def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" @@ -250,7 +250,7 @@ def _set_tables(self, tables): self._tables = tables def _set_default_permissions(self, permissions, content_type): - attr = f"_default_{content_type}_permissions" + attr = "_default_{content}_permissions".format(content=content_type) setattr( self, attr, diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 1b082c157..e4e71c4a2 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -1,7 +1,7 @@ import copy import datetime import xml.etree.ElementTree as ET -from typing import Optional +from typing import Dict, List, Optional, Set, Tuple from defusedxml.ElementTree import fromstring @@ -18,14 +18,14 @@ from tableauserverclient.models.tag_item import TagItem -class DatasourceItem: +class DatasourceItem(object): class AskDataEnablement: Enabled = "Enabled" Disabled = "Disabled" SiteDefault = "SiteDefault" def __repr__(self): - return "".format( + return "".format( self._id, self.name, self.description or "No Description", @@ -44,7 +44,7 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) self._encrypt_extracts = None self._has_extracts = None self._id: Optional[str] = None - self._initial_tags: set = set() + self._initial_tags: Set = set() self._project_name: Optional[str] = None self._revisions = None self._size: Optional[int] = None @@ -55,7 +55,7 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) self.name = name self.owner_id: Optional[str] = None self.project_id = project_id - self.tags: set[str] = set() + self.tags: Set[str] = set() self._permissions = None self._data_quality_warnings = None @@ -72,14 +72,14 @@ def ask_data_enablement(self, value: Optional[AskDataEnablement]): self._ask_data_enablement = value @property - def connections(self) -> Optional[list[ConnectionItem]]: + def connections(self) -> Optional[List[ConnectionItem]]: if self._connections is None: error = "Datasource item must be populated with connections first." raise UnpopulatedPropertyError(error) return self._connections() @property - def permissions(self) -> Optional[list[PermissionsRule]]: + def permissions(self) -> Optional[List[PermissionsRule]]: if self._permissions is None: error = "Project item must be populated with permissions first." raise UnpopulatedPropertyError(error) @@ -177,7 +177,7 @@ def webpage_url(self) -> Optional[str]: return self._webpage_url @property - def revisions(self) -> list[RevisionItem]: + def revisions(self) -> List[RevisionItem]: if self._revisions is None: error = "Datasource item must be populated with revisions first." raise UnpopulatedPropertyError(error) @@ -309,7 +309,7 @@ def _set_values( self._size = int(size) @classmethod - def from_response(cls, resp: str, ns: dict) -> list["DatasourceItem"]: + def from_response(cls, resp: str, ns: Dict) -> List["DatasourceItem"]: all_datasource_items = list() parsed_response = fromstring(resp) all_datasource_xml = parsed_response.findall(".//t:datasource", namespaces=ns) @@ -326,7 +326,7 @@ def from_xml(cls, datasource_xml, ns): return datasource_item @staticmethod - def _parse_element(datasource_xml: ET.Element, ns: dict) -> tuple: + def _parse_element(datasource_xml: ET.Element, ns: Dict) -> Tuple: id_ = datasource_xml.get("id", None) name = datasource_xml.get("name", None) datasource_type = datasource_xml.get("type", None) diff --git a/tableauserverclient/models/dqw_item.py b/tableauserverclient/models/dqw_item.py index fbda9d9f2..ada041481 100644 --- a/tableauserverclient/models/dqw_item.py +++ b/tableauserverclient/models/dqw_item.py @@ -3,7 +3,7 @@ from tableauserverclient.datetime_helpers import parse_datetime -class DQWItem: +class DQWItem(object): class WarningType: WARNING = "WARNING" DEPRECATED = "DEPRECATED" diff --git a/tableauserverclient/models/favorites_item.py b/tableauserverclient/models/favorites_item.py index 4fea280f7..caff755e3 100644 --- a/tableauserverclient/models/favorites_item.py +++ b/tableauserverclient/models/favorites_item.py @@ -1,27 +1,28 @@ import logging -from typing import Union from defusedxml.ElementTree import fromstring - from tableauserverclient.models.tableau_types import TableauItem + 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 +from typing import Dict, List, Union -FavoriteType = dict[ +FavoriteType = Dict[ str, - list[TableauItem], + List[TableauItem], ] class FavoriteItem: @classmethod - def from_response(cls, xml: Union[str, bytes], namespace: dict) -> FavoriteType: + def from_response(cls, xml: str, namespace: Dict) -> FavoriteType: favorites: FavoriteType = { "datasources": [], "flows": [], diff --git a/tableauserverclient/models/fileupload_item.py b/tableauserverclient/models/fileupload_item.py index aea4dfe1f..e9bdd25b2 100644 --- a/tableauserverclient/models/fileupload_item.py +++ b/tableauserverclient/models/fileupload_item.py @@ -1,7 +1,7 @@ from defusedxml.ElementTree import fromstring -class FileuploadItem: +class FileuploadItem(object): def __init__(self): self._file_size = None self._upload_session_id = None diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index 9bcad5e89..edce2ec97 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -1,7 +1,7 @@ import copy import datetime import xml.etree.ElementTree as ET -from typing import Optional +from typing import List, Optional, Set from defusedxml.ElementTree import fromstring @@ -14,9 +14,9 @@ from tableauserverclient.models.tag_item import TagItem -class FlowItem: +class FlowItem(object): def __repr__(self): - return " None: self._webpage_url: Optional[str] = None self._created_at: Optional[datetime.datetime] = None self._id: Optional[str] = None - self._initial_tags: set[str] = set() + self._initial_tags: Set[str] = set() self._project_name: Optional[str] = None self._updated_at: Optional[datetime.datetime] = None self.name: Optional[str] = name self.owner_id: Optional[str] = None self.project_id: str = project_id - self.tags: set[str] = set() + self.tags: Set[str] = set() self.description: Optional[str] = None self._connections: Optional[ConnectionItem] = None @@ -170,7 +170,7 @@ def _set_values( self.owner_id = owner_id @classmethod - def from_response(cls, resp, ns) -> list["FlowItem"]: + def from_response(cls, resp, ns) -> List["FlowItem"]: all_flow_items = list() parsed_response = fromstring(resp) all_flow_xml = parsed_response.findall(".//t:flow", namespaces=ns) diff --git a/tableauserverclient/models/flow_run_item.py b/tableauserverclient/models/flow_run_item.py index f2f1d561f..12281f4f8 100644 --- a/tableauserverclient/models/flow_run_item.py +++ b/tableauserverclient/models/flow_run_item.py @@ -1,13 +1,13 @@ import itertools from datetime import datetime -from typing import Optional +from typing import Dict, List, Optional, Type from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime -class FlowRunItem: +class FlowRunItem(object): def __init__(self) -> None: self._id: str = "" self._flow_id: Optional[str] = None @@ -71,7 +71,7 @@ def _set_values( self._background_job_id = background_job_id @classmethod - def from_response(cls: type["FlowRunItem"], resp: bytes, ns: Optional[dict]) -> list["FlowRunItem"]: + def from_response(cls: Type["FlowRunItem"], resp: bytes, ns: Optional[Dict]) -> List["FlowRunItem"]: all_flowrun_items = list() parsed_response = fromstring(resp) all_flowrun_xml = itertools.chain( diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index 6871f8b16..6c8f7eb01 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -1,4 +1,4 @@ -from typing import Callable, Optional, TYPE_CHECKING +from typing import Callable, List, Optional, TYPE_CHECKING from defusedxml.ElementTree import fromstring @@ -11,7 +11,7 @@ from tableauserverclient.server import Pager -class GroupItem: +class GroupItem(object): tag_name: str = "group" class LicenseMode: @@ -27,7 +27,7 @@ def __init__(self, name=None, domain_name=None) -> None: self.domain_name: Optional[str] = domain_name def __repr__(self): - return f"{self.__class__.__name__}({self.__dict__!r})" + return "{}({!r})".format(self.__class__.__name__, self.__dict__) @property def domain_name(self) -> Optional[str]: @@ -79,7 +79,7 @@ def _set_users(self, users: Callable[..., "Pager"]) -> None: self._users = users @classmethod - def from_response(cls, resp, ns) -> list["GroupItem"]: + def from_response(cls, resp, ns) -> List["GroupItem"]: all_group_items = list() parsed_response = fromstring(resp) all_group_xml = parsed_response.findall(".//t:group", namespaces=ns) diff --git a/tableauserverclient/models/groupset_item.py b/tableauserverclient/models/groupset_item.py index aa653a79e..ffb57adf5 100644 --- a/tableauserverclient/models/groupset_item.py +++ b/tableauserverclient/models/groupset_item.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Dict, List, Optional import xml.etree.ElementTree as ET from defusedxml.ElementTree import fromstring @@ -13,7 +13,7 @@ class GroupSetItem: def __init__(self, name: Optional[str] = None) -> None: self.name = name self.id: Optional[str] = None - self.groups: list["GroupItem"] = [] + self.groups: List["GroupItem"] = [] self.group_count: int = 0 def __str__(self) -> str: @@ -25,13 +25,13 @@ def __repr__(self) -> str: return self.__str__() @classmethod - def from_response(cls, response: bytes, ns: dict[str, str]) -> list["GroupSetItem"]: + def from_response(cls, response: bytes, ns: Dict[str, str]) -> List["GroupSetItem"]: parsed_response = fromstring(response) all_groupset_xml = parsed_response.findall(".//t:groupSet", namespaces=ns) return [cls.from_xml(xml, ns) for xml in all_groupset_xml] @classmethod - def from_xml(cls, groupset_xml: ET.Element, ns: dict[str, str]) -> "GroupSetItem": + def from_xml(cls, groupset_xml: ET.Element, ns: Dict[str, str]) -> "GroupSetItem": def get_group(group_xml: ET.Element) -> GroupItem: group_item = GroupItem() group_item._id = group_xml.get("id") diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index d7cf891cc..444674e19 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -1,7 +1,7 @@ from .property_decorators import property_is_valid_time, property_not_nullable -class IntervalItem: +class IntervalItem(object): class Frequency: Hourly = "Hourly" Daily = "Daily" @@ -25,7 +25,7 @@ class Day: LastDay = "LastDay" -class HourlyInterval: +class HourlyInterval(object): def __init__(self, start_time, end_time, interval_value): self.start_time = start_time self.end_time = end_time @@ -73,12 +73,12 @@ def interval(self, intervals): for interval in intervals: # if an hourly interval is a string, then it is a weekDay interval if isinstance(interval, str) and not interval.isnumeric() and not hasattr(IntervalItem.Day, interval): - error = f"Invalid weekDay interval {interval}" + error = "Invalid weekDay interval {}".format(interval) raise ValueError(error) # if an hourly interval is a number, it is an hours or minutes interval if isinstance(interval, (int, float)) and float(interval) not in VALID_INTERVALS: - error = f"Invalid interval {interval} not in {str(VALID_INTERVALS)}" + error = "Invalid interval {} not in {}".format(interval, str(VALID_INTERVALS)) raise ValueError(error) self._interval = intervals @@ -108,7 +108,7 @@ def _interval_type_pairs(self): return interval_type_pairs -class DailyInterval: +class DailyInterval(object): def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values @@ -141,12 +141,12 @@ def interval(self, intervals): for interval in intervals: # if an hourly interval is a string, then it is a weekDay interval if isinstance(interval, str) and not interval.isnumeric() and not hasattr(IntervalItem.Day, interval): - error = f"Invalid weekDay interval {interval}" + error = "Invalid weekDay interval {}".format(interval) raise ValueError(error) # if an hourly interval is a number, it is an hours or minutes interval if isinstance(interval, (int, float)) and float(interval) not in VALID_INTERVALS: - error = f"Invalid interval {interval} not in {str(VALID_INTERVALS)}" + error = "Invalid interval {} not in {}".format(interval, str(VALID_INTERVALS)) raise ValueError(error) self._interval = intervals @@ -176,7 +176,7 @@ def _interval_type_pairs(self): return interval_type_pairs -class WeeklyInterval: +class WeeklyInterval(object): def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values @@ -213,7 +213,7 @@ def _interval_type_pairs(self): return [(IntervalItem.Occurrence.WeekDay, day) for day in self.interval] -class MonthlyInterval: +class MonthlyInterval(object): def __init__(self, start_time, interval_value): self.start_time = start_time diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index cc7cd5811..155ce668b 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -1,5 +1,5 @@ import datetime -from typing import Optional +from typing import List, Optional from defusedxml.ElementTree import fromstring @@ -7,7 +7,7 @@ from tableauserverclient.models.flow_run_item import FlowRunItem -class JobItem: +class JobItem(object): class FinishCode: """ Status codes as documented on @@ -27,7 +27,7 @@ def __init__( started_at: Optional[datetime.datetime] = None, completed_at: Optional[datetime.datetime] = None, finish_code: int = 0, - notes: Optional[list[str]] = None, + notes: Optional[List[str]] = None, mode: Optional[str] = None, workbook_id: Optional[str] = None, datasource_id: Optional[str] = None, @@ -43,7 +43,7 @@ def __init__( self._started_at = started_at self._completed_at = completed_at self._finish_code = finish_code - self._notes: list[str] = notes or [] + self._notes: List[str] = notes or [] self._mode = mode self._workbook_id = workbook_id self._datasource_id = datasource_id @@ -81,7 +81,7 @@ def finish_code(self) -> int: return self._finish_code @property - def notes(self) -> list[str]: + def notes(self) -> List[str]: return self._notes @property @@ -139,7 +139,7 @@ def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" @classmethod - def from_response(cls, xml, ns) -> list["JobItem"]: + def from_response(cls, xml, ns) -> List["JobItem"]: parsed_response = fromstring(xml) all_tasks_xml = parsed_response.findall(".//t:job", namespaces=ns) @@ -191,7 +191,7 @@ def _parse_element(cls, element, ns): ) -class BackgroundJobItem: +class BackgroundJobItem(object): class Status: Pending: str = "Pending" InProgress: str = "InProgress" @@ -270,7 +270,7 @@ def priority(self) -> int: return self._priority @classmethod - def from_response(cls, xml, ns) -> list["BackgroundJobItem"]: + def from_response(cls, xml, ns) -> List["BackgroundJobItem"]: parsed_response = fromstring(xml) all_tasks_xml = parsed_response.findall(".//t:backgroundJob", namespaces=ns) return [cls._parse_element(x, ns) for x in all_tasks_xml] diff --git a/tableauserverclient/models/linked_tasks_item.py b/tableauserverclient/models/linked_tasks_item.py index 14a0e4978..ae9b60425 100644 --- a/tableauserverclient/models/linked_tasks_item.py +++ b/tableauserverclient/models/linked_tasks_item.py @@ -1,5 +1,5 @@ import datetime as dt -from typing import Optional +from typing import List, Optional from defusedxml.ElementTree import fromstring @@ -14,7 +14,7 @@ def __init__(self) -> None: self.schedule: Optional[ScheduleItem] = None @classmethod - def from_response(cls, resp: bytes, namespace) -> list["LinkedTaskItem"]: + def from_response(cls, resp: bytes, namespace) -> List["LinkedTaskItem"]: parsed_response = fromstring(resp) return [ cls._parse_element(x, namespace) @@ -35,10 +35,10 @@ def __init__(self) -> None: self.id: Optional[str] = None self.step_number: Optional[int] = None self.stop_downstream_on_failure: Optional[bool] = None - self.task_details: list[LinkedTaskFlowRunItem] = [] + self.task_details: List[LinkedTaskFlowRunItem] = [] @classmethod - def from_task_xml(cls, xml, namespace) -> list["LinkedTaskStepItem"]: + def from_task_xml(cls, xml, namespace) -> List["LinkedTaskStepItem"]: return [cls._parse_element(x, namespace) for x in xml.findall(".//t:linkedTaskSteps[@id]", namespace)] @classmethod @@ -61,7 +61,7 @@ def __init__(self) -> None: self.flow_name: Optional[str] = None @classmethod - def _parse_element(cls, xml, namespace) -> list["LinkedTaskFlowRunItem"]: + def _parse_element(cls, xml, namespace) -> List["LinkedTaskFlowRunItem"]: all_tasks = [] for flow_run in xml.findall(".//t:flowRun[@id]", namespace): task = cls() diff --git a/tableauserverclient/models/metric_item.py b/tableauserverclient/models/metric_item.py index 432fd861a..d8ba8e825 100644 --- a/tableauserverclient/models/metric_item.py +++ b/tableauserverclient/models/metric_item.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as ET from datetime import datetime -from typing import Optional +from typing import List, Optional, Set from tableauserverclient.datetime_helpers import parse_datetime from .property_decorators import property_is_boolean, property_is_datetime @@ -8,7 +8,7 @@ from .permissions_item import Permission -class MetricItem: +class MetricItem(object): def __init__(self, name: Optional[str] = None): self._id: Optional[str] = None self._name: Optional[str] = name @@ -21,8 +21,8 @@ def __init__(self, name: Optional[str] = None): self._project_name: Optional[str] = None self._owner_id: Optional[str] = None self._view_id: Optional[str] = None - self._initial_tags: set[str] = set() - self.tags: set[str] = set() + self._initial_tags: Set[str] = set() + self.tags: Set[str] = set() self._permissions: Optional[Permission] = None @property @@ -126,7 +126,7 @@ def from_response( cls, resp: bytes, ns, - ) -> list["MetricItem"]: + ) -> List["MetricItem"]: all_metric_items = list() parsed_response = ET.fromstring(resp) all_metric_xml = parsed_response.findall(".//t:metric", namespaces=ns) diff --git a/tableauserverclient/models/pagination_item.py b/tableauserverclient/models/pagination_item.py index f30519be5..8cebd1c86 100644 --- a/tableauserverclient/models/pagination_item.py +++ b/tableauserverclient/models/pagination_item.py @@ -1,7 +1,7 @@ from defusedxml.ElementTree import fromstring -class PaginationItem: +class PaginationItem(object): def __init__(self): self._page_number = None self._page_size = None diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index bb3487279..26f4ee7e8 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -1,5 +1,5 @@ import xml.etree.ElementTree as ET -from typing import Optional +from typing import Dict, List, Optional from defusedxml.ElementTree import fromstring @@ -36,25 +36,23 @@ class Capability: ShareView = "ShareView" ViewComments = "ViewComments" ViewUnderlyingData = "ViewUnderlyingData" - VizqlDataApiAccess = "VizqlDataApiAccess" WebAuthoring = "WebAuthoring" Write = "Write" RunExplainData = "RunExplainData" CreateRefreshMetrics = "CreateRefreshMetrics" SaveAs = "SaveAs" - PulseMetricDefine = "PulseMetricDefine" def __repr__(self): return "" class PermissionsRule: - def __init__(self, grantee: ResourceReference, capabilities: dict[str, str]) -> None: + def __init__(self, grantee: ResourceReference, capabilities: Dict[str, str]) -> None: self.grantee = grantee self.capabilities = capabilities def __repr__(self): - return f"" + return "".format(self.grantee, self.capabilities) def __eq__(self, other: object) -> bool: if not hasattr(other, "grantee") or not hasattr(other, "capabilities"): @@ -68,7 +66,7 @@ def __and__(self, other: "PermissionsRule") -> "PermissionsRule": if self.capabilities == other.capabilities: return self - capabilities = {*self.capabilities.keys(), *other.capabilities.keys()} + capabilities = set((*self.capabilities.keys(), *other.capabilities.keys())) new_capabilities = {} for capability in capabilities: if (self.capabilities.get(capability), other.capabilities.get(capability)) == ( @@ -88,7 +86,7 @@ def __or__(self, other: "PermissionsRule") -> "PermissionsRule": if self.capabilities == other.capabilities: return self - capabilities = {*self.capabilities.keys(), *other.capabilities.keys()} + 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)): @@ -102,14 +100,14 @@ def __or__(self, other: "PermissionsRule") -> "PermissionsRule": return PermissionsRule(self.grantee, new_capabilities) @classmethod - def from_response(cls, resp, ns=None) -> list["PermissionsRule"]: + def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: parsed_response = fromstring(resp) rules = [] permissions_rules_list_xml = parsed_response.findall(".//t:granteeCapabilities", namespaces=ns) for grantee_capability_xml in permissions_rules_list_xml: - capability_dict: dict[str, str] = {} + capability_dict: Dict[str, str] = {} grantee = PermissionsRule._parse_grantee_element(grantee_capability_xml, ns) @@ -118,7 +116,7 @@ def from_response(cls, resp, ns=None) -> list["PermissionsRule"]: mode = capability_xml.get("mode") if name is None or mode is None: - logger.error(f"Capability was not valid: {capability_xml}") + logger.error("Capability was not valid: {}".format(capability_xml)) raise UnpopulatedPropertyError() else: capability_dict[name] = mode @@ -129,7 +127,7 @@ def from_response(cls, resp, ns=None) -> list["PermissionsRule"]: return rules @staticmethod - def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[dict[str, str]]) -> ResourceReference: + def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[Dict[str, str]]) -> ResourceReference: """Use Xpath magic and some string splitting to get the right object type from the xml""" # Get the first element in the tree with an 'id' attribute @@ -148,6 +146,6 @@ def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[dict elif grantee_type == "groupSet": grantee = GroupSetItem.as_reference(grantee_id) else: - raise UnknownGranteeTypeError(f"No support for grantee type of {grantee_type}") + raise UnknownGranteeTypeError("No support for grantee type of {}".format(grantee_type)) return grantee diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 48f27c60c..9fb382885 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -1,6 +1,6 @@ import logging import xml.etree.ElementTree as ET -from typing import Optional +from typing import List, Optional from defusedxml.ElementTree import fromstring @@ -8,16 +8,14 @@ from tableauserverclient.models.property_decorators import property_is_enum, property_not_empty -class ProjectItem: - ERROR_MSG = "Project item must be populated with permissions first." - +class ProjectItem(object): class ContentPermissions: LockedToProject: str = "LockedToProject" ManagedByOwner: str = "ManagedByOwner" LockedToProjectWithoutNested: str = "LockedToProjectWithoutNested" def __repr__(self): - return "".format( + return "".format( self._id, self.name, self.parent_id or "None (Top level)", self.content_permissions or "Not Set" ) @@ -45,9 +43,6 @@ def __init__( self._default_lens_permissions = None self._default_datarole_permissions = None self._default_metric_permissions = None - self._default_virtualconnection_permissions = None - self._default_database_permissions = None - self._default_table_permissions = None @property def content_permissions(self): @@ -61,63 +56,52 @@ def content_permissions(self, value: Optional[str]) -> None: @property def permissions(self): if self._permissions is None: - raise UnpopulatedPropertyError(self.ERROR_MSG) + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) return self._permissions() @property def default_datasource_permissions(self): if self._default_datasource_permissions is None: - raise UnpopulatedPropertyError(self.ERROR_MSG) + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) return self._default_datasource_permissions() @property def default_workbook_permissions(self): if self._default_workbook_permissions is None: - raise UnpopulatedPropertyError(self.ERROR_MSG) + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) return self._default_workbook_permissions() @property def default_flow_permissions(self): if self._default_flow_permissions is None: - raise UnpopulatedPropertyError(self.ERROR_MSG) + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) return self._default_flow_permissions() @property def default_lens_permissions(self): if self._default_lens_permissions is None: - raise UnpopulatedPropertyError(self.ERROR_MSG) + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) return self._default_lens_permissions() @property def default_datarole_permissions(self): if self._default_datarole_permissions is None: - raise UnpopulatedPropertyError(self.ERROR_MSG) + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) return self._default_datarole_permissions() @property def default_metric_permissions(self): if self._default_metric_permissions is None: - raise UnpopulatedPropertyError(self.ERROR_MSG) + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) return self._default_metric_permissions() - @property - def default_virtualconnection_permissions(self): - if self._default_virtualconnection_permissions is None: - raise UnpopulatedPropertyError(self.ERROR_MSG) - return self._default_virtualconnection_permissions() - - @property - def default_database_permissions(self): - if self._default_database_permissions is None: - raise UnpopulatedPropertyError(self.ERROR_MSG) - return self._default_database_permissions() - - @property - def default_table_permissions(self): - if self._default_table_permissions is None: - raise UnpopulatedPropertyError(self.ERROR_MSG) - return self._default_table_permissions() - @property def id(self) -> Optional[str]: return self._id @@ -174,7 +158,7 @@ def _set_permissions(self, permissions): self._permissions = permissions def _set_default_permissions(self, permissions, content_type): - attr = f"_default_{content_type}_permissions" + attr = "_default_{content}_permissions".format(content=content_type) setattr( self, attr, @@ -182,7 +166,7 @@ def _set_default_permissions(self, permissions, content_type): ) @classmethod - def from_response(cls, resp, ns) -> list["ProjectItem"]: + def from_response(cls, resp, ns) -> List["ProjectItem"]: all_project_items = list() parsed_response = fromstring(resp) all_project_xml = parsed_response.findall(".//t:project", namespaces=ns) diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index 5048b3498..ce31b1428 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -1,8 +1,7 @@ import datetime import re from functools import wraps -from typing import Any, Optional -from collections.abc import Container +from typing import Any, Container, Optional, Tuple from tableauserverclient.datetime_helpers import parse_datetime @@ -12,7 +11,7 @@ def property_type_decorator(func): @wraps(func) def wrapper(self, value): if value is not None and not hasattr(enum_type, value): - error = f"Invalid value: {value}. {func.__name__} must be of type {enum_type.__name__}." + error = "Invalid value: {0}. {1} must be of type {2}.".format(value, func.__name__, enum_type.__name__) raise ValueError(error) return func(self, value) @@ -25,7 +24,7 @@ def property_is_boolean(func): @wraps(func) def wrapper(self, value): if not isinstance(value, bool): - error = f"Boolean expected for {func.__name__} flag." + error = "Boolean expected for {0} flag.".format(func.__name__) raise ValueError(error) return func(self, value) @@ -36,7 +35,7 @@ def property_not_nullable(func): @wraps(func) def wrapper(self, value): if value is None: - error = f"{func.__name__} must be defined." + error = "{0} must be defined.".format(func.__name__) raise ValueError(error) return func(self, value) @@ -47,7 +46,7 @@ def property_not_empty(func): @wraps(func) def wrapper(self, value): if not value: - error = f"{func.__name__} must not be empty." + error = "{0} must not be empty.".format(func.__name__) raise ValueError(error) return func(self, value) @@ -67,7 +66,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. @@ -82,7 +81,7 @@ def property_is_int(range: tuple[int, int], allowed: Optional[Container[Any]] = def property_type_decorator(func): @wraps(func) def wrapper(self, value): - error = f"Invalid property defined: '{value}'. Integer value expected." + error = "Invalid property defined: '{}'. Integer value expected.".format(value) if range is None: if isinstance(value, int): @@ -134,7 +133,7 @@ def wrapper(self, value): return func(self, value) if not isinstance(value, str): raise ValueError( - f"Cannot convert {value.__class__.__name__} into a datetime, cannot update {func.__name__}" + "Cannot convert {} into a datetime, cannot update {}".format(value.__class__.__name__, func.__name__) ) dt = parse_datetime(value) @@ -147,11 +146,11 @@ def property_is_data_acceleration_config(func): @wraps(func) def wrapper(self, value): if not isinstance(value, dict): - raise ValueError(f"{value.__class__.__name__} is not type 'dict', cannot update {func.__name__})") + raise ValueError("{} is not type 'dict', cannot update {})".format(value.__class__.__name__, func.__name__)) if len(value) < 2 or not all(attr in value.keys() for attr in ("acceleration_enabled", "accelerate_now")): - error = f"{func.__name__} should have 2 keys " + error = "{} should have 2 keys ".format(func.__name__) error += "'acceleration_enabled' and 'accelerate_now'" - error += f"instead you have {value.keys()}" + error += "instead you have {}".format(value.keys()) raise ValueError(error) return func(self, value) diff --git a/tableauserverclient/models/reference_item.py b/tableauserverclient/models/reference_item.py index 4c1fff564..710548fcc 100644 --- a/tableauserverclient/models/reference_item.py +++ b/tableauserverclient/models/reference_item.py @@ -1,10 +1,10 @@ -class ResourceReference: +class ResourceReference(object): def __init__(self, id_, tag_name): self.id = id_ self.tag_name = tag_name def __str__(self): - return f"" + return "".format(self._id, self._tag_name) __repr__ = __str__ diff --git a/tableauserverclient/models/revision_item.py b/tableauserverclient/models/revision_item.py index 1b4cc6249..a0e6a1bd5 100644 --- a/tableauserverclient/models/revision_item.py +++ b/tableauserverclient/models/revision_item.py @@ -1,12 +1,12 @@ from datetime import datetime -from typing import Optional +from typing import List, Optional from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime -class RevisionItem: +class RevisionItem(object): def __init__(self): self._resource_id: Optional[str] = None self._resource_name: Optional[str] = None @@ -56,7 +56,7 @@ def __repr__(self): ) @classmethod - def from_response(cls, resp: bytes, ns, resource_item) -> list["RevisionItem"]: + def from_response(cls, resp: bytes, ns, resource_item) -> List["RevisionItem"]: all_revision_items = list() parsed_response = fromstring(resp) all_revision_xml = parsed_response.findall(".//t:revision", namespaces=ns) diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index e39042058..e416643ba 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -19,7 +19,7 @@ Interval = Union[HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval] -class ScheduleItem: +class ScheduleItem(object): class Type: Extract = "Extract" Flow = "Flow" @@ -336,7 +336,7 @@ def parse_add_to_schedule_response(response, ns): all_task_xml = parsed_response.findall(".//t:task", namespaces=ns) error = ( - f"Status {response.status_code}: {response.reason}" + "Status {}: {}".format(response.status_code, response.reason) if response.status_code < 200 or response.status_code >= 300 else None ) diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py index b13f26740..57fc51af9 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -6,29 +6,7 @@ from tableauserverclient.helpers.logging import logger -class ServerInfoItem: - """ - The ServerInfoItem class contains the build and version information for - Tableau Server. The server information is accessed with the - server_info.get() method, which returns an instance of the ServerInfo class. - - Attributes - ---------- - product_version : str - Shows the version of the Tableau Server or Tableau Cloud - (for example, 10.2.0). - - build_number : str - Shows the specific build number (for example, 10200.17.0329.1446). - - rest_api_version : str - Shows the supported REST API version number. Note that this might be - different from the default value specified for the server, with the - Server.version attribute. To take advantage of new features, you should - query the server and set the Server.version to match the supported REST - API version number. - """ - +class ServerInfoItem(object): def __init__(self, product_version, build_number, rest_api_version): self._product_version = product_version self._build_number = build_number @@ -62,11 +40,13 @@ def from_response(cls, resp, ns): try: parsed_response = fromstring(resp) except xml.etree.ElementTree.ParseError as error: - logger.exception(f"Unexpected response for ServerInfo: {resp}") + logger.info("Unexpected response for ServerInfo: {}".format(resp)) + logger.info(error) return cls("Unknown", "Unknown", "Unknown") except Exception as error: - logger.exception(f"Unexpected response for ServerInfo: {resp}") - raise error + logger.info("Unexpected response for ServerInfo: {}".format(resp)) + logger.info(error) + return cls("Unknown", "Unknown", "Unknown") product_version_tag = parsed_response.find(".//t:productVersion", namespaces=ns) rest_api_version_tag = parsed_response.find(".//t:restApiVersion", namespaces=ns) diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index e4e146f9c..b651e5773 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -14,79 +14,13 @@ VALID_CONTENT_URL_RE = r"^[a-zA-Z0-9_\-]*$" -from typing import Optional, Union, TYPE_CHECKING +from typing import List, Optional, Union, TYPE_CHECKING if TYPE_CHECKING: from tableauserverclient.server import Server -class SiteItem: - """ - The SiteItem class contains the members or attributes for the site resources - on Tableau Server or Tableau Cloud. The SiteItem class defines the - information you can request or query from Tableau Server or Tableau Cloud. - The class members correspond to the attributes of a server request or - response payload. - - Attributes - ---------- - name: str - The name of the site. The name of the default site is "". - - content_url: str - The path to the site. - - admin_mode: str - (Optional) For Tableau Server only. Specify ContentAndUsers to allow - site administrators to use the server interface and tabcmd commands to - add and remove users. (Specifying this option does not give site - administrators permissions to manage users using the REST API.) Specify - ContentOnly to prevent site administrators from adding or removing - users. (Server administrators can always add or remove users.) - - user_quota: int - (Optional) Specifies the total number of users for the site. The number - can't exceed the number of licenses activated for the site; and if - tiered capacity attributes are set, then user_quota will equal the sum - of the tiered capacity values, and attempting to set user_quota will - cause an error. - - tier_explorer_capacity: int - tier_creator_capacity: int - tier_viewer_capacity: int - (Optional) The maximum number of licenses for users with the Creator, - Explorer, or Viewer role, respectively, allowed on a site. - - storage_quota: int - (Optional) Specifies the maximum amount of space for the new site, in - megabytes. If you set a quota and the site exceeds it, publishers will - be prevented from uploading new content until the site is under the - limit again. - - disable_subscriptions: bool - (Optional) Specify true to prevent users from being able to subscribe - to workbooks on the specified site. The default is False. - - subscribe_others_enabled: bool - (Optional) Specify false to prevent server administrators, site - administrators, and project or content owners from being able to - subscribe other users to workbooks on the specified site. The default - is True. - - revision_history_enabled: bool - (Optional) Specify true to enable revision history for content resources - (workbooks and datasources). The default is False. - - revision_limit: int - (Optional) Specifies the number of revisions of a content source - (workbook or data source) to allow. On Tableau Server, the default is - 25. - - state: str - Shows the current state of the site (Active or Suspended). - - """ - +class SiteItem(object): _user_quota: Optional[int] = None _tier_creator_capacity: Optional[int] = None _tier_explorer_capacity: Optional[int] = None @@ -939,7 +873,7 @@ def _set_values( self.auto_suspend_refresh_inactivity_window = auto_suspend_refresh_inactivity_window @classmethod - def from_response(cls, resp, ns) -> list["SiteItem"]: + def from_response(cls, resp, ns) -> List["SiteItem"]: all_site_items = list() parsed_response = fromstring(resp) all_site_xml = parsed_response.findall(".//t:site", namespaces=ns) diff --git a/tableauserverclient/models/subscription_item.py b/tableauserverclient/models/subscription_item.py index 61c75e2d6..e96fcc448 100644 --- a/tableauserverclient/models/subscription_item.py +++ b/tableauserverclient/models/subscription_item.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING +from typing import List, Type, TYPE_CHECKING from defusedxml.ElementTree import fromstring @@ -10,7 +10,7 @@ from .target import Target -class SubscriptionItem: +class SubscriptionItem(object): def __init__(self, subject: str, schedule_id: str, user_id: str, target: "Target") -> None: self._id = None self.attach_image = True @@ -79,7 +79,7 @@ def suspended(self, value: bool) -> None: self._suspended = value @classmethod - def from_response(cls: type, xml: bytes, ns) -> list["SubscriptionItem"]: + def from_response(cls: Type, xml: bytes, ns) -> List["SubscriptionItem"]: parsed_response = fromstring(xml) all_subscriptions_xml = parsed_response.findall(".//t:subscription", namespaces=ns) diff --git a/tableauserverclient/models/table_item.py b/tableauserverclient/models/table_item.py index 0afdd4df3..f9df8a8f3 100644 --- a/tableauserverclient/models/table_item.py +++ b/tableauserverclient/models/table_item.py @@ -4,7 +4,7 @@ from .property_decorators import property_not_empty, property_is_boolean -class TableItem: +class TableItem(object): def __init__(self, name, description=None): self._id = None self.description = description diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 7d7981433..10cf58723 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" @@ -32,43 +32,6 @@ def deprecate_site_attribute(): # The traditional auth type: username/password class TableauAuth(Credentials): - """ - The TableauAuth class defines the information you can set in a sign-in - request. The class members correspond to the attributes of a server request - or response payload. To use this class, create a new instance, supplying - user name, password, and site information if necessary, and pass the - request object to the Auth.sign_in method. - - Parameters - ---------- - username : str - The user name for the sign-in request. - - password : str - The password for the sign-in request. - - site_id : str, optional - This corresponds to the contentUrl attribute in the Tableau REST API. - The site_id is the portion of the URL that follows the /site/ in the - URL. For example, "MarketingTeam" is the site_id in the following URL - MyServer/#/site/MarketingTeam/projects. To specify the default site on - Tableau Server, you can use an empty string '' (single quotes, no - space). For Tableau Cloud, you must provide a value for the site_id. - - user_id_to_impersonate : str, optional - Specifies the id (not the name) of the user to sign in as. This is not - available for Tableau Online. - - Examples - -------- - >>> import tableauserverclient as TSC - - >>> tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD', site_id='CONTENTURL') - >>> server = TSC.Server('https://SERVER_URL', use_server_version=True) - >>> server.auth.sign_in(tableau_auth) - - """ - def __init__( self, username: str, password: str, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None ) -> None: @@ -79,7 +42,7 @@ def __init__( 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): @@ -92,43 +55,6 @@ def __repr__(self): # A Tableau-generated Personal Access Token class PersonalAccessTokenAuth(Credentials): - """ - The PersonalAccessTokenAuth class defines the information you can set in a sign-in - request. The class members correspond to the attributes of a server request - or response payload. To use this class, create a new instance, supplying - token name, token secret, and site information if necessary, and pass the - request object to the Auth.sign_in method. - - Parameters - ---------- - token_name : str - The name of the personal access token. - - personal_access_token : str - The personal access token secret for the sign in request. - - site_id : str, optional - This corresponds to the contentUrl attribute in the Tableau REST API. - The site_id is the portion of the URL that follows the /site/ in the - URL. For example, "MarketingTeam" is the site_id in the following URL - MyServer/#/site/MarketingTeam/projects. To specify the default site on - Tableau Server, you can use an empty string '' (single quotes, no - space). For Tableau Cloud, you must provide a value for the site_id. - - user_id_to_impersonate : str, optional - Specifies the id (not the name) of the user to sign in as. This is not - available for Tableau Online. - - Examples - -------- - >>> import tableauserverclient as TSC - - >>> tableau_auth = TSC.PersonalAccessTokenAuth("token_name", "token_secret", site_id='CONTENTURL') - >>> server = TSC.Server('https://SERVER_URL', use_server_version=True) - >>> server.auth.sign_in(tableau_auth) - - """ - def __init__( self, token_name: str, @@ -143,7 +69,7 @@ def __init__( 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, @@ -162,42 +88,6 @@ def __repr__(self): # A standard JWT generated specifically for Tableau class JWTAuth(Credentials): - """ - The JWTAuth class defines the information you can set in a sign-in - request. The class members correspond to the attributes of a server request - or response payload. To use this class, create a new instance, supplying - an encoded JSON Web Token, and site information if necessary, and pass the - request object to the Auth.sign_in method. - - Parameters - ---------- - token : str - The encoded JSON Web Token. - - site_id : str, optional - This corresponds to the contentUrl attribute in the Tableau REST API. - The site_id is the portion of the URL that follows the /site/ in the - URL. For example, "MarketingTeam" is the site_id in the following URL - MyServer/#/site/MarketingTeam/projects. To specify the default site on - Tableau Server, you can use an empty string '' (single quotes, no - space). For Tableau Cloud, you must provide a value for the site_id. - - user_id_to_impersonate : str, optional - Specifies the id (not the name) of the user to sign in as. This is not - available for Tableau Online. - - Examples - -------- - >>> import jwt - >>> import tableauserverclient as TSC - - >>> jwt_token = jwt.encode(...) - >>> tableau_auth = TSC.JWTAuth(token, site_id='CONTENTURL') - >>> server = TSC.Server('https://SERVER_URL', use_server_version=True) - >>> server.auth.sign_in(tableau_auth) - - """ - 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") @@ -205,7 +95,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): diff --git a/tableauserverclient/models/tableau_types.py b/tableauserverclient/models/tableau_types.py index 01ee3d3a9..bac072076 100644 --- a/tableauserverclient/models/tableau_types.py +++ b/tableauserverclient/models/tableau_types.py @@ -28,8 +28,8 @@ class Resource: TableauItem = Union[DatasourceItem, FlowItem, MetricItem, ProjectItem, ViewItem, WorkbookItem, VirtualConnectionItem] -def plural_type(content_type: Union[Resource, str]) -> str: +def plural_type(content_type: Resource) -> str: if content_type == Resource.Lens: return "lenses" else: - return f"{content_type}s" + return "{}s".format(content_type) diff --git a/tableauserverclient/models/tag_item.py b/tableauserverclient/models/tag_item.py index cde755f05..afa0a0762 100644 --- a/tableauserverclient/models/tag_item.py +++ b/tableauserverclient/models/tag_item.py @@ -1,15 +1,16 @@ import xml.etree.ElementTree as ET +from typing import Set from defusedxml.ElementTree import fromstring -class TagItem: +class TagItem(object): @classmethod - def from_response(cls, resp: bytes, ns) -> set[str]: + def from_response(cls, resp: bytes, ns) -> Set[str]: return cls.from_xml_element(fromstring(resp), ns) @classmethod - def from_xml_element(cls, parsed_response: ET.Element, ns) -> set[str]: + def from_xml_element(cls, parsed_response: ET.Element, ns) -> Set[str]: all_tags = set() tag_elem = parsed_response.findall(".//t:tag", namespaces=ns) for tag_xml in tag_elem: diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index fa6f782ba..01cfcfb11 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Optional +from typing import List, Optional from defusedxml.ElementTree import fromstring @@ -8,7 +8,7 @@ from tableauserverclient.models.target import Target -class TaskItem: +class TaskItem(object): class Type: ExtractRefresh = "extractRefresh" DataAcceleration = "dataAcceleration" @@ -48,9 +48,9 @@ def __repr__(self) -> str: ) @classmethod - def from_response(cls, xml, ns, task_type=Type.ExtractRefresh) -> list["TaskItem"]: + def from_response(cls, xml, ns, task_type=Type.ExtractRefresh) -> List["TaskItem"]: parsed_response = fromstring(xml) - all_tasks_xml = parsed_response.findall(f".//t:task/t:{task_type}", namespaces=ns) + all_tasks_xml = parsed_response.findall(".//t:task/t:{}".format(task_type), namespaces=ns) all_tasks = (TaskItem._parse_element(x, ns) for x in all_tasks_xml) diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 365e44c1d..fe659575a 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -2,7 +2,7 @@ import xml.etree.ElementTree as ET from datetime import datetime from enum import IntEnum -from typing import Optional, TYPE_CHECKING +from typing import Dict, List, Optional, TYPE_CHECKING, Tuple from defusedxml.ElementTree import fromstring @@ -18,35 +18,10 @@ from tableauserverclient.server import Pager -class UserItem: - """ - The UserItem class contains the members or attributes for the view - resources on Tableau Server. The UserItem class defines the information you - can request or query from Tableau Server. The class attributes correspond - to the attributes of a server request or response payload. - - - Parameters - ---------- - name: str - The name of the user. - - site_role: str - The role of the user on the site. - - auth_setting: str - Required attribute for Tableau Cloud. How the user autenticates to the - server. - """ - +class UserItem(object): tag_name: str = "user" class Roles: - """ - The Roles class contains the possible roles for a user on Tableau - Server. - """ - Interactor = "Interactor" Publisher = "Publisher" ServerAdministrator = "ServerAdministrator" @@ -68,11 +43,6 @@ class Roles: SupportUser = "SupportUser" class Auth: - """ - The Auth class contains the possible authentication settings for a user - on Tableau Cloud. - """ - OpenID = "OpenID" SAML = "SAML" TableauIDWithMFA = "TableauIDWithMFA" @@ -87,7 +57,7 @@ def __init__( self._id: Optional[str] = None self._last_login: Optional[datetime] = None self._workbooks = None - self._favorites: Optional[dict[str, list]] = None + self._favorites: Optional[Dict[str, List]] = None self._groups = None self.email: Optional[str] = None self.fullname: Optional[str] = None @@ -99,7 +69,7 @@ def __init__( def __str__(self) -> str: str_site_role = self.site_role or "None" - return f"" + return "".format(self.id, self.name, str_site_role) def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" @@ -171,7 +141,7 @@ def workbooks(self) -> "Pager": return self._workbooks() @property - def favorites(self) -> dict[str, list]: + def favorites(self) -> Dict[str, List]: if self._favorites is None: error = "User item must be populated with favorites first." raise UnpopulatedPropertyError(error) @@ -240,12 +210,12 @@ def _set_values( self._domain_name = domain_name @classmethod - def from_response(cls, resp, ns) -> list["UserItem"]: + def from_response(cls, resp, ns) -> List["UserItem"]: element_name = ".//t:user" return cls._parse_xml(element_name, resp, ns) @classmethod - def from_response_as_owner(cls, resp, ns) -> list["UserItem"]: + def from_response_as_owner(cls, resp, ns) -> List["UserItem"]: element_name = ".//t:owner" return cls._parse_xml(element_name, resp, ns) @@ -313,7 +283,7 @@ def _parse_element(user_xml, ns): domain_name, ) - class CSVImport: + class CSVImport(object): """ This class includes hardcoded options and logic for the CSV file format defined for user import https://help.tableau.com/current/server/en-us/users_import.htm @@ -338,7 +308,7 @@ def create_user_from_line(line: str): if line is None or line is False or line == "\n" or line == "": return None line = line.strip().lower() - values: list[str] = list(map(str.strip, line.split(","))) + values: List[str] = list(map(str.strip, line.split(","))) user = UserItem(values[UserItem.CSVImport.ColumnType.USERNAME]) if len(values) > 1: if len(values) > UserItem.CSVImport.ColumnType.MAX: @@ -367,7 +337,7 @@ def create_user_from_line(line: str): # Read through an entire CSV file meant for user import # Return the number of valid lines and a list of all the invalid lines @staticmethod - def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> tuple[int, list[str]]: + def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> Tuple[int, List[str]]: num_valid_lines = 0 invalid_lines = [] csv_file.seek(0) # set to start of file in case it has been read earlier @@ -375,11 +345,11 @@ def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> tuple[int, l while line and line != "": try: # do not print passwords - logger.info(f"Reading user {line[:4]}") + logger.info("Reading user {}".format(line[:4])) UserItem.CSVImport._validate_import_line_or_throw(line, logger) num_valid_lines += 1 except Exception as exc: - logger.info(f"Error parsing {line[:4]}: {exc}") + logger.info("Error parsing {}: {}".format(line[:4], exc)) invalid_lines.append(line) line = csv_file.readline() return num_valid_lines, invalid_lines @@ -388,7 +358,7 @@ def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> tuple[int, l # Iterate through each field and validate the given value against hardcoded constraints @staticmethod def _validate_import_line_or_throw(incoming, logger) -> None: - _valid_attributes: list[list[str]] = [ + _valid_attributes: List[List[str]] = [ [], [], [], @@ -403,23 +373,23 @@ def _validate_import_line_or_throw(incoming, logger) -> None: if len(line) > UserItem.CSVImport.ColumnType.MAX: raise AttributeError("Too many attributes in line") username = line[UserItem.CSVImport.ColumnType.USERNAME.value] - logger.debug(f"> details - {username}") + logger.debug("> details - {}".format(username)) UserItem.validate_username_or_throw(username) for i in range(1, len(line)): - logger.debug(f"column {UserItem.CSVImport.ColumnType(i).name}: {line[i]}") + logger.debug("column {}: {}".format(UserItem.CSVImport.ColumnType(i).name, line[i])) UserItem.CSVImport._validate_attribute_value( line[i], _valid_attributes[i], UserItem.CSVImport.ColumnType(i) ) # Given a restricted set of possible values, confirm the item is in that set @staticmethod - def _validate_attribute_value(item: str, possible_values: list[str], column_type) -> None: + def _validate_attribute_value(item: str, possible_values: List[str], column_type) -> None: if item is None or item == "": # value can be empty for any column except user, which is checked elsewhere return if item in possible_values or possible_values == []: return - raise AttributeError(f"Invalid value {item} for {column_type}") + raise AttributeError("Invalid value {} for {}".format(item, column_type)) # https://help.tableau.com/current/server/en-us/csvguidelines.htm#settings_and_site_roles # This logic is hardcoded to match the existing rules for import csv files diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index dc5f37a48..a26e364a3 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -1,8 +1,7 @@ import copy from datetime import datetime from requests import Response -from typing import Callable, Optional -from collections.abc import Iterator +from typing import Callable, Iterator, List, Optional, Set from defusedxml.ElementTree import fromstring @@ -12,13 +11,13 @@ from .tag_item import TagItem -class ViewItem: +class ViewItem(object): def __init__(self) -> None: self._content_url: Optional[str] = None self._created_at: Optional[datetime] = None self._id: Optional[str] = None self._image: Optional[Callable[[], bytes]] = None - self._initial_tags: set[str] = set() + self._initial_tags: Set[str] = set() self._name: Optional[str] = None self._owner_id: Optional[str] = None self._preview_image: Optional[Callable[[], bytes]] = None @@ -30,15 +29,15 @@ def __init__(self) -> None: self._sheet_type: Optional[str] = None self._updated_at: Optional[datetime] = None self._workbook_id: Optional[str] = None - self._permissions: Optional[Callable[[], list[PermissionsRule]]] = None - self.tags: set[str] = set() + 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( + return "".format( self._id, self.name, self.content_url, self.project_id ) @@ -147,21 +146,21 @@ def data_acceleration_config(self, value): self._data_acceleration_config = value @property - def permissions(self) -> list[PermissionsRule]: + def permissions(self) -> List[PermissionsRule]: if self._permissions is None: error = "View item must be populated with permissions first." raise UnpopulatedPropertyError(error) return self._permissions() - def _set_permissions(self, permissions: Callable[[], list[PermissionsRule]]) -> None: + def _set_permissions(self, permissions: Callable[[], List[PermissionsRule]]) -> None: self._permissions = permissions @classmethod - def from_response(cls, resp: "Response", ns, workbook_id="") -> list["ViewItem"]: + def from_response(cls, resp: "Response", ns, workbook_id="") -> List["ViewItem"]: return cls.from_xml_element(fromstring(resp), ns, workbook_id) @classmethod - def from_xml_element(cls, parsed_response, ns, workbook_id="") -> list["ViewItem"]: + def from_xml_element(cls, parsed_response, ns, workbook_id="") -> List["ViewItem"]: all_view_items = list() all_view_xml = parsed_response.findall(".//t:view", namespaces=ns) for view_xml in all_view_xml: diff --git a/tableauserverclient/models/virtual_connection_item.py b/tableauserverclient/models/virtual_connection_item.py index e9e22be1e..76a3b5dea 100644 --- a/tableauserverclient/models/virtual_connection_item.py +++ b/tableauserverclient/models/virtual_connection_item.py @@ -1,7 +1,6 @@ import datetime as dt import json -from typing import Callable, Optional -from collections.abc import Iterable +from typing import Callable, Dict, Iterable, List, Optional from xml.etree.ElementTree import Element from defusedxml.ElementTree import fromstring @@ -24,7 +23,7 @@ def __init__(self, name: str) -> None: self._connections: Optional[Callable[[], Iterable[ConnectionItem]]] = None self.project_id: Optional[str] = None self.owner_id: Optional[str] = None - self.content: Optional[dict[str, dict]] = None + self.content: Optional[Dict[str, dict]] = None self.certification_note: Optional[str] = None def __str__(self) -> str: @@ -41,7 +40,7 @@ def id(self) -> Optional[str]: return self._id @property - def permissions(self) -> list[PermissionsRule]: + def permissions(self) -> List[PermissionsRule]: if self._permissions is None: error = "Workbook item must be populated with permissions first." raise UnpopulatedPropertyError(error) @@ -54,12 +53,12 @@ def connections(self) -> Iterable[ConnectionItem]: return self._connections() @classmethod - def from_response(cls, response: bytes, ns: dict[str, str]) -> list["VirtualConnectionItem"]: + def from_response(cls, response: bytes, ns: Dict[str, str]) -> List["VirtualConnectionItem"]: parsed_response = fromstring(response) return [cls.from_xml(xml, ns) for xml in parsed_response.findall(".//t:virtualConnection[@name]", ns)] @classmethod - def from_xml(cls, xml: Element, ns: dict[str, str]) -> "VirtualConnectionItem": + def from_xml(cls, xml: Element, ns: Dict[str, str]) -> "VirtualConnectionItem": v_conn = cls(xml.get("name", "")) v_conn._id = xml.get("id", None) v_conn.webpage_url = xml.get("webpageUrl", None) diff --git a/tableauserverclient/models/webhook_item.py b/tableauserverclient/models/webhook_item.py index 98d821fb4..e4d5e4aa0 100644 --- a/tableauserverclient/models/webhook_item.py +++ b/tableauserverclient/models/webhook_item.py @@ -1,6 +1,6 @@ import re import xml.etree.ElementTree as ET -from typing import Optional +from typing import List, Optional, Tuple, Type from defusedxml.ElementTree import fromstring @@ -13,7 +13,7 @@ def _parse_event(events): return NAMESPACE_RE.sub("", event.tag) -class WebhookItem: +class WebhookItem(object): def __init__(self): self._id: Optional[str] = None self.name: Optional[str] = None @@ -45,10 +45,10 @@ def event(self) -> Optional[str]: @event.setter def event(self, value: str) -> None: - self._event = f"webhook-source-event-{value}" + self._event = "webhook-source-event-{}".format(value) @classmethod - def from_response(cls: type["WebhookItem"], resp: bytes, ns) -> list["WebhookItem"]: + def from_response(cls: Type["WebhookItem"], resp: bytes, ns) -> List["WebhookItem"]: all_webhooks_items = list() parsed_response = fromstring(resp) all_webhooks_xml = parsed_response.findall(".//t:webhook", namespaces=ns) @@ -61,7 +61,7 @@ def from_response(cls: type["WebhookItem"], resp: bytes, ns) -> list["WebhookIte return all_webhooks_items @staticmethod - def _parse_element(webhook_xml: ET.Element, ns) -> tuple: + def _parse_element(webhook_xml: ET.Element, ns) -> Tuple: id = webhook_xml.get("id", None) name = webhook_xml.get("name", None) @@ -82,4 +82,4 @@ def _parse_element(webhook_xml: ET.Element, ns) -> tuple: return id, name, url, event, owner_id def __repr__(self) -> str: - return f"" + return "".format(self.id, self.name, self.url, self.event) diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 776d041e3..58fd2a9a9 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -2,7 +2,7 @@ import datetime import uuid import xml.etree.ElementTree as ET -from typing import Callable, Optional +from typing import Callable, Dict, List, Optional, Set from defusedxml.ElementTree import fromstring @@ -20,85 +20,7 @@ from .data_freshness_policy_item import DataFreshnessPolicyItem -class WorkbookItem: - """ - The workbook resources for Tableau are defined in the WorkbookItem class. - The class corresponds to the workbook resources you can access using the - Tableau REST API. Some workbook methods take an instance of the WorkbookItem - class as arguments. The workbook item specifies the project. - - Parameters - ---------- - project_id : Optional[str], optional - The project ID for the workbook, by default None. - - name : Optional[str], optional - The name of the workbook, by default None. - - show_tabs : bool, optional - Determines whether the workbook shows tabs for the view. - - Attributes - ---------- - connections : list[ConnectionItem] - The list of data connections (ConnectionItem) for the data sources used - by the workbook. You must first call the workbooks.populate_connections - method to access this data. See the ConnectionItem class. - - content_url : Optional[str] - The name of the workbook as it appears in the URL. - - created_at : Optional[datetime.datetime] - The date and time the workbook was created. - - description : Optional[str] - User-defined description of the workbook. - - id : Optional[str] - The identifier for the workbook. You need this value to query a specific - workbook or to delete a workbook with the get_by_id and delete methods. - - owner_id : Optional[str] - The identifier for the owner (UserItem) of the workbook. - - preview_image : bytes - The thumbnail image for the view. You must first call the - workbooks.populate_preview_image method to access this data. - - project_name : Optional[str] - The name of the project that contains the workbook. - - size: int - The size of the workbook in megabytes. - - hidden_views: Optional[list[str]] - List of string names of views that need to be hidden when the workbook - is published. - - tags: set[str] - The set of tags associated with the workbook. - - updated_at : Optional[datetime.datetime] - The date and time the workbook was last updated. - - views : list[ViewItem] - The list of views (ViewItem) for the workbook. You must first call the - workbooks.populate_views method to access this data. See the ViewItem - class. - - web_page_url : Optional[str] - The full URL for the workbook. - - Examples - -------- - # creating a new instance of a WorkbookItem - >>> import tableauserverclient as TSC - - >>> # Create new workbook_item with project id '3a8b6148-493c-11e6-a621-6f3499394a39' - - >>> new_workbook = TSC.WorkbookItem('3a8b6148-493c-11e6-a621-6f3499394a39') - """ - +class WorkbookItem(object): def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, show_tabs: bool = False) -> None: self._connections = None self._content_url = None @@ -113,15 +35,15 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, self._revisions = None self._size = None self._updated_at = None - self._views: Optional[Callable[[], list[ViewItem]]] = None + self._views: Optional[Callable[[], List[ViewItem]]] = None self.name = name self._description = None self.owner_id: Optional[str] = None # workaround for Personal Space workbooks without a project self.project_id: Optional[str] = project_id or uuid.uuid4().__str__() self.show_tabs = show_tabs - self.hidden_views: Optional[list[str]] = None - self.tags: set[str] = set() + self.hidden_views: Optional[List[str]] = None + self.tags: Set[str] = set() self.data_acceleration_config = { "acceleration_enabled": None, "accelerate_now": None, @@ -134,7 +56,7 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, return None def __str__(self): - return "".format( + return "".format( self._id, self.name, self.content_url, self.project_id ) @@ -142,14 +64,14 @@ def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" @property - def connections(self) -> list[ConnectionItem]: + def connections(self) -> List[ConnectionItem]: if self._connections is None: error = "Workbook item must be populated with connections first." raise UnpopulatedPropertyError(error) return self._connections() @property - def permissions(self) -> list[PermissionsRule]: + def permissions(self) -> List[PermissionsRule]: if self._permissions is None: error = "Workbook item must be populated with permissions first." raise UnpopulatedPropertyError(error) @@ -230,7 +152,7 @@ def updated_at(self) -> Optional[datetime.datetime]: return self._updated_at @property - def views(self) -> list[ViewItem]: + def views(self) -> List[ViewItem]: # Views can be set in an initial workbook response OR by a call # to Server. Without getting too fancy, I think we can rely on # returning a list from the response, until they call @@ -269,7 +191,7 @@ def data_freshness_policy(self, value): self._data_freshness_policy = value @property - def revisions(self) -> list[RevisionItem]: + def revisions(self) -> List[RevisionItem]: if self._revisions is None: error = "Workbook item must be populated with revisions first." raise UnpopulatedPropertyError(error) @@ -281,7 +203,7 @@ def _set_connections(self, connections): def _set_permissions(self, permissions): self._permissions = permissions - def _set_views(self, views: Callable[[], list[ViewItem]]) -> None: + def _set_views(self, views: Callable[[], List[ViewItem]]) -> None: self._views = views def _set_pdf(self, pdf: Callable[[], bytes]) -> None: @@ -394,7 +316,7 @@ def _set_values( self.data_freshness_policy = data_freshness_policy @classmethod - def from_response(cls, resp: str, ns: dict[str, str]) -> list["WorkbookItem"]: + def from_response(cls, resp: str, ns: Dict[str, str]) -> List["WorkbookItem"]: all_workbook_items = list() parsed_response = fromstring(resp) all_workbook_xml = parsed_response.findall(".//t:workbook", namespaces=ns) diff --git a/tableauserverclient/namespace.py b/tableauserverclient/namespace.py index 54ac46d8d..d225ecff6 100644 --- a/tableauserverclient/namespace.py +++ b/tableauserverclient/namespace.py @@ -11,7 +11,7 @@ class UnknownNamespaceError(Exception): pass -class Namespace: +class Namespace(object): def __init__(self): self._namespace = {"t": NEW_NAMESPACE} self._detected = False diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index 87cc9460b..f5cd1d236 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -11,7 +11,7 @@ from tableauserverclient.server.sort import Sort from tableauserverclient.server.server import Server from tableauserverclient.server.pager import Pager -from tableauserverclient.server.endpoint.exceptions import FailedSignInError, NotSignedInError +from tableauserverclient.server.endpoint.exceptions import NotSignedInError from tableauserverclient.server.endpoint import ( Auth, @@ -57,7 +57,6 @@ "Sort", "Server", "Pager", - "FailedSignInError", "NotSignedInError", "Auth", "CustomViews", diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 4211bb7ea..468d469a7 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -16,7 +16,7 @@ class Auth(Endpoint): - class contextmgr: + class contextmgr(object): def __init__(self, callback): self._callback = callback @@ -28,7 +28,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/auth" + return "{0}/auth".format(self.parent_srv.baseurl) @api(version="2.0") def sign_in(self, auth_req: "Credentials") -> contextmgr: @@ -41,32 +41,8 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr: optionally a user_id to impersonate. Creates a context manager that will sign out of the server upon exit. - - Parameters - ---------- - auth_req : Credentials - The credentials object to use for signing in. Can be a TableauAuth, - PersonalAccessTokenAuth, or JWTAuth object. - - Returns - ------- - contextmgr - A context manager that will sign out of the server upon exit. - - Examples - -------- - >>> import tableauserverclient as TSC - - >>> # create an auth object - >>> tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') - - >>> # create an instance for your server - >>> server = TSC.Server('https://SERVER_URL') - - >>> # call the sign-in method with the auth object - >>> server.auth.sign_in(tableau_auth) """ - url = f"{self.baseurl}/signin" + url = "{0}/{1}".format(self.baseurl, "signin") signin_req = RequestFactory.Auth.signin_req(auth_req) server_response = self.parent_srv.session.post( url, data=signin_req, **self.parent_srv.http_options, allow_redirects=False @@ -87,25 +63,22 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr: user_id = parsed_response.find(".//t:user", namespaces=self.parent_srv.namespace).get("id", None) auth_token = parsed_response.find("t:credentials", namespaces=self.parent_srv.namespace).get("token", None) self.parent_srv._set_auth(site_id, user_id, auth_token) - logger.info(f"Signed into {self.parent_srv.server_address} as user with id {user_id}") + logger.info("Signed into {0} as user with id {1}".format(self.parent_srv.server_address, user_id)) return Auth.contextmgr(self.sign_out) # We use the same request that username/password login uses for all auth types. # The distinct methods are mostly useful for explicitly showing api version support for each auth type @api(version="3.6") def sign_in_with_personal_access_token(self, auth_req: "Credentials") -> contextmgr: - """Passthrough to sign_in method""" return self.sign_in(auth_req) @api(version="3.17") def sign_in_with_json_web_token(self, auth_req: "Credentials") -> contextmgr: - """Passthrough to sign_in method""" return self.sign_in(auth_req) @api(version="2.0") def sign_out(self) -> None: - """Sign out of current session.""" - url = f"{self.baseurl}/signout" + 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(): return @@ -115,34 +88,7 @@ def sign_out(self) -> None: @api(version="2.6") def switch_site(self, site_item: "SiteItem") -> contextmgr: - """ - Switch to a different site on the server. This will sign out of the - current site and sign in to the new site. If used as a context manager, - will sign out of the new site upon exit. - - Parameters - ---------- - site_item : SiteItem - The site to switch to. - - Returns - ------- - contextmgr - A context manager that will sign out of the new site upon exit. - - Examples - -------- - >>> import tableauserverclient as TSC - - >>> # Find the site you want to switch to - >>> new_site = server.sites.get_by_id("9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d") - >>> # switch to the new site - >>> with server.auth.switch_site(new_site): - >>> # do something on the new site - >>> pass - - """ - url = f"{self.baseurl}/switchSite" + url = "{0}/{1}".format(self.baseurl, "switchSite") switch_req = RequestFactory.Auth.switch_req(site_item.content_url) try: server_response = self.post_request(url, switch_req) @@ -158,14 +104,11 @@ def switch_site(self, site_item: "SiteItem") -> contextmgr: user_id = parsed_response.find(".//t:user", namespaces=self.parent_srv.namespace).get("id", None) auth_token = parsed_response.find("t:credentials", namespaces=self.parent_srv.namespace).get("token", None) self.parent_srv._set_auth(site_id, user_id, auth_token) - logger.info(f"Signed into {self.parent_srv.server_address} as user with id {user_id}") + logger.info("Signed into {0} as user with id {1}".format(self.parent_srv.server_address, user_id)) return Auth.contextmgr(self.sign_out) @api(version="3.10") def revoke_all_server_admin_tokens(self) -> None: - """ - Revokes all personal access tokens for all server admins on the server. - """ - url = f"{self.baseurl}/revokeAllServerAdminTokens" + 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/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index b02b05d78..57a5b0100 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -1,23 +1,15 @@ import io import logging import os -from contextlib import closing from pathlib import Path -from typing import Optional, Union -from collections.abc import Iterator +from typing import List, Optional, Tuple, Union -from tableauserverclient.config import BYTES_PER_MB, config +from tableauserverclient.config import BYTES_PER_MB, FILESIZE_LIMIT_MB from tableauserverclient.filesys_helpers import get_file_object_size from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError from tableauserverclient.models import CustomViewItem, PaginationItem -from tableauserverclient.server import ( - RequestFactory, - RequestOptions, - ImageRequestOptions, - PDFRequestOptions, - CSVRequestOptions, -) +from tableauserverclient.server import RequestFactory, RequestOptions, ImageRequestOptions from tableauserverclient.helpers.logging import logger @@ -41,11 +33,11 @@ class CustomViews(QuerysetEndpoint[CustomViewItem]): def __init__(self, parent_srv): - super().__init__(parent_srv) + super(CustomViews, self).__init__(parent_srv) @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/customviews" + return "{0}/sites/{1}/customviews".format(self.parent_srv.baseurl, self.parent_srv.site_id) @property def expurl(self) -> str: @@ -63,7 +55,7 @@ def expurl(self) -> str: """ @api(version="3.18") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[CustomViewItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[CustomViewItem], PaginationItem]: logger.info("Querying all custom views on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -76,8 +68,8 @@ def get_by_id(self, view_id: str) -> Optional[CustomViewItem]: if not view_id: error = "Custom view item missing ID." raise MissingRequiredFieldError(error) - logger.info(f"Querying custom view (ID: {view_id})") - url = f"{self.baseurl}/{view_id}" + logger.info("Querying custom view (ID: {0})".format(view_id)) + url = "{0}/{1}".format(self.baseurl, view_id) server_response = self.get_request(url) return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace) @@ -91,53 +83,17 @@ def image_fetcher(): return self._get_view_image(view_item, req_options) view_item._set_image(image_fetcher) - logger.info(f"Populated image for custom view (ID: {view_item.id})") + logger.info("Populated image for custom view (ID: {0})".format(view_item.id)) def _get_view_image(self, view_item: CustomViewItem, req_options: Optional["ImageRequestOptions"]) -> bytes: - url = f"{self.baseurl}/{view_item.id}/image" + url = "{0}/{1}/image".format(self.baseurl, view_item.id) server_response = self.get_request(url, req_options) image = server_response.content return image - @api(version="3.23") - def populate_pdf(self, custom_view_item: CustomViewItem, req_options: Optional["PDFRequestOptions"] = None) -> None: - if not custom_view_item.id: - error = "Custom View item missing ID." - raise MissingRequiredFieldError(error) - - def pdf_fetcher(): - return self._get_custom_view_pdf(custom_view_item, req_options) - - custom_view_item._set_pdf(pdf_fetcher) - logger.info(f"Populated pdf for custom view (ID: {custom_view_item.id})") - - def _get_custom_view_pdf( - self, custom_view_item: CustomViewItem, req_options: Optional["PDFRequestOptions"] - ) -> bytes: - url = f"{self.baseurl}/{custom_view_item.id}/pdf" - server_response = self.get_request(url, req_options) - pdf = server_response.content - return pdf - - @api(version="3.23") - def populate_csv(self, custom_view_item: CustomViewItem, req_options: Optional["CSVRequestOptions"] = None) -> None: - if not custom_view_item.id: - error = "Custom View item missing ID." - raise MissingRequiredFieldError(error) - - def csv_fetcher(): - return self._get_custom_view_csv(custom_view_item, req_options) - - custom_view_item._set_csv(csv_fetcher) - logger.info(f"Populated csv for custom view (ID: {custom_view_item.id})") - - def _get_custom_view_csv( - self, custom_view_item: CustomViewItem, req_options: Optional["CSVRequestOptions"] - ) -> Iterator[bytes]: - url = f"{self.baseurl}/{custom_view_item.id}/data" - - with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: - yield from server_response.iter_content(1024) + """ + Not yet implemented: pdf or csv exports + """ @api(version="3.18") def update(self, view_item: CustomViewItem) -> Optional[CustomViewItem]: @@ -149,10 +105,10 @@ def update(self, view_item: CustomViewItem) -> Optional[CustomViewItem]: return view_item # Update the custom view owner or name - url = f"{self.baseurl}/{view_item.id}" + url = "{0}/{1}".format(self.baseurl, view_item.id) update_req = RequestFactory.CustomView.update_req(view_item) server_response = self.put_request(url, update_req) - logger.info(f"Updated custom view (ID: {view_item.id})") + logger.info("Updated custom view (ID: {0})".format(view_item.id)) return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace) # Delete 1 view by id @@ -161,9 +117,9 @@ def delete(self, view_id: str) -> None: if not view_id: error = "Custom View ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{view_id}" + url = "{0}/{1}".format(self.baseurl, view_id) self.delete_request(url) - logger.info(f"Deleted single custom view (ID: {view_id})") + logger.info("Deleted single custom view (ID: {0})".format(view_id)) @api(version="3.21") def download(self, view_item: CustomViewItem, file: PathOrFileW) -> PathOrFileW: @@ -188,7 +144,7 @@ def publish(self, view_item: CustomViewItem, file: PathOrFileR) -> Optional[Cust else: raise ValueError("File path or file object required for publishing custom view.") - if size >= config.FILESIZE_LIMIT_MB * BYTES_PER_MB: + if size >= FILESIZE_LIMIT_MB * BYTES_PER_MB: upload_session_id = self.parent_srv.fileuploads.upload(file) url = f"{url}?uploadSessionId={upload_session_id}" xml_request, content_type = RequestFactory.CustomView.publish_req_chunked(view_item) diff --git a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py index 579001156..256a6e766 100644 --- a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py +++ b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py @@ -10,14 +10,14 @@ class DataAccelerationReport(Endpoint): def __init__(self, parent_srv): - super().__init__(parent_srv) + super(DataAccelerationReport, self).__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl) @property def baseurl(self): - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/dataAccelerationReport" + return "{0}/sites/{1}/dataAccelerationReport".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="3.8") def get(self, req_options=None): diff --git a/tableauserverclient/server/endpoint/data_alert_endpoint.py b/tableauserverclient/server/endpoint/data_alert_endpoint.py index ba3ecd74f..fd02d2e4a 100644 --- a/tableauserverclient/server/endpoint/data_alert_endpoint.py +++ b/tableauserverclient/server/endpoint/data_alert_endpoint.py @@ -7,7 +7,7 @@ from tableauserverclient.helpers.logging import logger -from typing import Optional, TYPE_CHECKING, Union +from typing import List, Optional, TYPE_CHECKING, Tuple, Union if TYPE_CHECKING: @@ -17,14 +17,14 @@ class DataAlerts(Endpoint): def __init__(self, parent_srv: "Server") -> None: - super().__init__(parent_srv) + super(DataAlerts, self).__init__(parent_srv) @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/dataAlerts" + return "{0}/sites/{1}/dataAlerts".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="3.2") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[DataAlertItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[DataAlertItem], PaginationItem]: logger.info("Querying all dataAlerts on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -38,8 +38,8 @@ def get_by_id(self, dataAlert_id: str) -> DataAlertItem: if not dataAlert_id: error = "dataAlert ID undefined." raise ValueError(error) - logger.info(f"Querying single dataAlert (ID: {dataAlert_id})") - url = f"{self.baseurl}/{dataAlert_id}" + logger.info("Querying single dataAlert (ID: {0})".format(dataAlert_id)) + url = "{0}/{1}".format(self.baseurl, dataAlert_id) server_response = self.get_request(url) return DataAlertItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -55,9 +55,9 @@ def delete(self, dataAlert: Union[DataAlertItem, str]) -> None: error = "Dataalert ID undefined." raise ValueError(error) # DELETE /api/api-version/sites/site-id/dataAlerts/data-alert-id/users/user-id - url = f"{self.baseurl}/{dataAlert_id}" + url = "{0}/{1}".format(self.baseurl, dataAlert_id) self.delete_request(url) - logger.info(f"Deleted single dataAlert (ID: {dataAlert_id})") + logger.info("Deleted single dataAlert (ID: {0})".format(dataAlert_id)) @api(version="3.2") def delete_user_from_alert(self, dataAlert: Union[DataAlertItem, str], user: Union[UserItem, str]) -> None: @@ -80,9 +80,9 @@ def delete_user_from_alert(self, dataAlert: Union[DataAlertItem, str], user: Uni error = "User ID undefined." raise ValueError(error) # DELETE /api/api-version/sites/site-id/dataAlerts/data-alert-id/users/user-id - url = f"{self.baseurl}/{dataAlert_id}/users/{user_id}" + url = "{0}/{1}/users/{2}".format(self.baseurl, dataAlert_id, user_id) self.delete_request(url) - logger.info(f"Deleted User (ID {user_id}) from dataAlert (ID: {dataAlert_id})") + logger.info("Deleted User (ID {0}) from dataAlert (ID: {1})".format(user_id, dataAlert_id)) @api(version="3.2") def add_user_to_alert(self, dataAlert_item: DataAlertItem, user: Union[UserItem, str]) -> UserItem: @@ -98,10 +98,10 @@ def add_user_to_alert(self, dataAlert_item: DataAlertItem, user: Union[UserItem, if not user_id: error = "User ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{dataAlert_item.id}/users" + url = "{0}/{1}/users".format(self.baseurl, dataAlert_item.id) update_req = RequestFactory.DataAlert.add_user_to_alert(dataAlert_item, user_id) server_response = self.post_request(url, update_req) - logger.info(f"Added user (ID {user_id}) to dataAlert item (ID: {dataAlert_item.id})") + logger.info("Added user (ID {0}) to dataAlert item (ID: {1})".format(user_id, dataAlert_item.id)) added_user = UserItem.from_response(server_response.content, self.parent_srv.namespace)[0] return added_user @@ -111,9 +111,9 @@ def update(self, dataAlert_item: DataAlertItem) -> DataAlertItem: error = "Dataalert item missing ID." raise MissingRequiredFieldError(error) - url = f"{self.baseurl}/{dataAlert_item.id}" + url = "{0}/{1}".format(self.baseurl, dataAlert_item.id) update_req = RequestFactory.DataAlert.update_req(dataAlert_item) server_response = self.put_request(url, update_req) - logger.info(f"Updated dataAlert item (ID: {dataAlert_item.id})") + logger.info("Updated dataAlert item (ID: {0})".format(dataAlert_item.id)) updated_dataAlert = DataAlertItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_dataAlert diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index c0e106eb2..2f8fece07 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -1,6 +1,5 @@ import logging -from typing import Union -from collections.abc import Iterable +from typing import Union, Iterable, Set from tableauserverclient.server.endpoint.default_permissions_endpoint import _DefaultPermissionsEndpoint from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint @@ -16,7 +15,7 @@ class Databases(Endpoint, TaggingMixin): def __init__(self, parent_srv): - super().__init__(parent_srv) + super(Databases, self).__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl) @@ -24,7 +23,7 @@ def __init__(self, parent_srv): @property def baseurl(self): - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/databases" + return "{0}/sites/{1}/databases".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="3.5") def get(self, req_options=None): @@ -41,8 +40,8 @@ def get_by_id(self, database_id): if not database_id: error = "database ID undefined." raise ValueError(error) - logger.info(f"Querying single database (ID: {database_id})") - url = f"{self.baseurl}/{database_id}" + logger.info("Querying single database (ID: {0})".format(database_id)) + url = "{0}/{1}".format(self.baseurl, database_id) server_response = self.get_request(url) return DatabaseItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -51,9 +50,9 @@ def delete(self, database_id): if not database_id: error = "Database ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{database_id}" + url = "{0}/{1}".format(self.baseurl, database_id) self.delete_request(url) - logger.info(f"Deleted single database (ID: {database_id})") + logger.info("Deleted single database (ID: {0})".format(database_id)) @api(version="3.5") def update(self, database_item): @@ -61,10 +60,10 @@ def update(self, database_item): error = "Database item missing ID." raise MissingRequiredFieldError(error) - url = f"{self.baseurl}/{database_item.id}" + url = "{0}/{1}".format(self.baseurl, database_item.id) update_req = RequestFactory.Database.update_req(database_item) server_response = self.put_request(url, update_req) - logger.info(f"Updated database item (ID: {database_item.id})") + logger.info("Updated database item (ID: {0})".format(database_item.id)) updated_database = DatabaseItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_database @@ -79,10 +78,10 @@ def column_fetcher(): return self._get_tables_for_database(database_item) database_item._set_tables(column_fetcher) - logger.info(f"Populated tables for database (ID: {database_item.id}") + logger.info("Populated tables for database (ID: {0}".format(database_item.id)) def _get_tables_for_database(self, database_item): - url = f"{self.baseurl}/{database_item.id}/tables" + url = "{0}/{1}/tables".format(self.baseurl, database_item.id) server_response = self.get_request(url) tables = TableItem.from_response(server_response.content, self.parent_srv.namespace) return tables @@ -128,7 +127,7 @@ def delete_dqw(self, item): self._data_quality_warnings.clear(item) @api(version="3.9") - def add_tags(self, item: Union[DatabaseItem, str], tags: Iterable[str]) -> set[str]: + def add_tags(self, item: Union[DatabaseItem, str], tags: Iterable[str]) -> Set[str]: return super().add_tags(item, tags) @api(version="3.9") diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 6bd809c28..7f3a47075 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -6,8 +6,7 @@ from contextlib import closing from pathlib import Path -from typing import Optional, TYPE_CHECKING, Union -from collections.abc import Iterable, Mapping, Sequence +from typing import Iterable, List, Mapping, Optional, Sequence, Set, Tuple, TYPE_CHECKING, Union from tableauserverclient.helpers.headers import fix_filename from tableauserverclient.server.query import QuerySet @@ -23,7 +22,7 @@ from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin -from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, BYTES_PER_MB, config +from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, FILESIZE_LIMIT_MB, BYTES_PER_MB, config from tableauserverclient.filesys_helpers import ( make_download_path, get_file_type, @@ -58,7 +57,7 @@ class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]): def __init__(self, parent_srv: "Server") -> None: - super().__init__(parent_srv) + super(Datasources, self).__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "datasource") @@ -66,11 +65,11 @@ def __init__(self, parent_srv: "Server") -> None: @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/datasources" + return "{0}/sites/{1}/datasources".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Get all datasources @api(version="2.0") - def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[DatasourceItem], PaginationItem]: + def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[DatasourceItem], PaginationItem]: logger.info("Querying all datasources on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -84,8 +83,8 @@ def get_by_id(self, datasource_id: str) -> DatasourceItem: if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) - logger.info(f"Querying single datasource (ID: {datasource_id})") - url = f"{self.baseurl}/{datasource_id}" + logger.info("Querying single datasource (ID: {0})".format(datasource_id)) + url = "{0}/{1}".format(self.baseurl, datasource_id) server_response = self.get_request(url) return DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -100,10 +99,10 @@ def connections_fetcher(): return self._get_datasource_connections(datasource_item) datasource_item._set_connections(connections_fetcher) - logger.info(f"Populated connections for datasource (ID: {datasource_item.id})") + logger.info("Populated connections for datasource (ID: {0})".format(datasource_item.id)) def _get_datasource_connections(self, datasource_item, req_options=None): - url = f"{self.baseurl}/{datasource_item.id}/connections" + url = "{0}/{1}/connections".format(self.baseurl, datasource_item.id) server_response = self.get_request(url, req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) return connections @@ -114,9 +113,9 @@ def delete(self, datasource_id: str) -> None: if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{datasource_id}" + url = "{0}/{1}".format(self.baseurl, datasource_id) self.delete_request(url) - logger.info(f"Deleted single datasource (ID: {datasource_id})") + logger.info("Deleted single datasource (ID: {0})".format(datasource_id)) # Download 1 datasource by id @api(version="2.0") @@ -153,11 +152,11 @@ def update(self, datasource_item: DatasourceItem) -> DatasourceItem: self.update_tags(datasource_item) # Update the datasource itself - url = f"{self.baseurl}/{datasource_item.id}" + url = "{0}/{1}".format(self.baseurl, datasource_item.id) update_req = RequestFactory.Datasource.update_req(datasource_item) server_response = self.put_request(url, update_req) - logger.info(f"Updated datasource item (ID: {datasource_item.id})") + logger.info("Updated datasource item (ID: {0})".format(datasource_item.id)) updated_datasource = copy.copy(datasource_item) return updated_datasource._parse_common_elements(server_response.content, self.parent_srv.namespace) @@ -166,7 +165,7 @@ def update(self, datasource_item: DatasourceItem) -> DatasourceItem: def update_connection( self, datasource_item: DatasourceItem, connection_item: ConnectionItem ) -> Optional[ConnectionItem]: - url = f"{self.baseurl}/{datasource_item.id}/connections/{connection_item.id}" + url = "{0}/{1}/connections/{2}".format(self.baseurl, datasource_item.id, connection_item.id) update_req = RequestFactory.Connection.update_req(connection_item) server_response = self.put_request(url, update_req) @@ -175,16 +174,18 @@ def update_connection( return None if len(connections) > 1: - logger.debug(f"Multiple connections returned ({len(connections)})") + logger.debug("Multiple connections returned ({0})".format(len(connections))) connection = list(filter(lambda x: x.id == connection_item.id, connections))[0] - logger.info(f"Updated datasource item (ID: {datasource_item.id} & connection item {connection_item.id}") + logger.info( + "Updated datasource item (ID: {0} & connection item {1}".format(datasource_item.id, connection_item.id) + ) return connection @api(version="2.8") def refresh(self, datasource_item: DatasourceItem) -> JobItem: id_ = getattr(datasource_item, "id", datasource_item) - url = f"{self.baseurl}/{id_}/refresh" + url = "{0}/{1}/refresh".format(self.baseurl, id_) empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -193,7 +194,7 @@ def refresh(self, datasource_item: DatasourceItem) -> JobItem: @api(version="3.5") def create_extract(self, datasource_item: DatasourceItem, encrypt: bool = False) -> JobItem: id_ = getattr(datasource_item, "id", datasource_item) - url = f"{self.baseurl}/{id_}/createExtract?encrypt={encrypt}" + url = "{0}/{1}/createExtract?encrypt={2}".format(self.baseurl, id_, encrypt) empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -202,7 +203,7 @@ def create_extract(self, datasource_item: DatasourceItem, encrypt: bool = False) @api(version="3.5") def delete_extract(self, datasource_item: DatasourceItem) -> None: id_ = getattr(datasource_item, "id", datasource_item) - url = f"{self.baseurl}/{id_}/deleteExtract" + url = "{0}/{1}/deleteExtract".format(self.baseurl, id_) empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) @@ -222,12 +223,12 @@ def publish( if isinstance(file, (os.PathLike, str)): if not os.path.isfile(file): error = "File path does not lead to an existing file." - raise OSError(error) + raise IOError(error) filename = os.path.basename(file) file_extension = os.path.splitext(filename)[1][1:] file_size = os.path.getsize(file) - logger.debug(f"Publishing file `{filename}`, size `{file_size}`") + logger.debug("Publishing file `{}`, size `{}`".format(filename, file_size)) # If name is not defined, grab the name from the file to publish if not datasource_item.name: datasource_item.name = os.path.splitext(filename)[0] @@ -246,10 +247,10 @@ def publish( elif file_type == "xml": file_extension = "tds" else: - error = f"Unsupported file type {file_type}" + error = "Unsupported file type {}".format(file_type) raise ValueError(error) - filename = f"{datasource_item.name}.{file_extension}" + filename = "{}.{}".format(datasource_item.name, file_extension) file_size = get_file_object_size(file) else: @@ -260,27 +261,27 @@ def publish( raise ValueError(error) # Construct the url with the defined mode - url = f"{self.baseurl}?datasourceType={file_extension}" + url = "{0}?datasourceType={1}".format(self.baseurl, file_extension) if mode == self.parent_srv.PublishMode.Overwrite or mode == self.parent_srv.PublishMode.Append: - url += f"&{mode.lower()}=true" + url += "&{0}=true".format(mode.lower()) if as_job: - url += "&{}=true".format("asJob") + url += "&{0}=true".format("asJob") # Determine if chunking is required (64MB is the limit for single upload method) - if file_size >= config.FILESIZE_LIMIT_MB * BYTES_PER_MB: + if file_size >= FILESIZE_LIMIT_MB * BYTES_PER_MB: logger.info( "Publishing {} to server with chunking method (datasource over {}MB, chunk size {}MB)".format( - filename, config.FILESIZE_LIMIT_MB, config.CHUNK_SIZE_MB + filename, FILESIZE_LIMIT_MB, config.CHUNK_SIZE_MB ) ) upload_session_id = self.parent_srv.fileuploads.upload(file) - url = f"{url}&uploadSessionId={upload_session_id}" + url = "{0}&uploadSessionId={1}".format(url, upload_session_id) xml_request, content_type = RequestFactory.Datasource.publish_req_chunked( datasource_item, connection_credentials, connections ) else: - logger.info(f"Publishing {filename} to server") + logger.info("Publishing {0} to server".format(filename)) if isinstance(file, (Path, str)): with open(file, "rb") as f: @@ -308,11 +309,11 @@ def publish( if as_job: new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Published {filename} (JOB_ID: {new_job.id}") + logger.info("Published {0} (JOB_ID: {1}".format(filename, new_job.id)) return new_job else: new_datasource = DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Published {filename} (ID: {new_datasource.id})") + logger.info("Published {0} (ID: {1})".format(filename, new_datasource.id)) return new_datasource @api(version="3.13") @@ -326,23 +327,23 @@ def update_hyper_data( ) -> JobItem: if isinstance(datasource_or_connection_item, DatasourceItem): datasource_id = datasource_or_connection_item.id - url = f"{self.baseurl}/{datasource_id}/data" + url = "{0}/{1}/data".format(self.baseurl, datasource_id) elif isinstance(datasource_or_connection_item, ConnectionItem): datasource_id = datasource_or_connection_item.datasource_id connection_id = datasource_or_connection_item.id - url = f"{self.baseurl}/{datasource_id}/connections/{connection_id}/data" + url = "{0}/{1}/connections/{2}/data".format(self.baseurl, datasource_id, connection_id) else: assert isinstance(datasource_or_connection_item, str) - url = f"{self.baseurl}/{datasource_or_connection_item}/data" + url = "{0}/{1}/data".format(self.baseurl, datasource_or_connection_item) if payload is not None: if not os.path.isfile(payload): error = "File path does not lead to an existing file." - raise OSError(error) + raise IOError(error) - logger.info(f"Uploading {payload} to server with chunking method for Update job") + logger.info("Uploading {0} to server with chunking method for Update job".format(payload)) upload_session_id = self.parent_srv.fileuploads.upload(payload) - url = f"{url}?uploadSessionId={upload_session_id}" + url = "{0}?uploadSessionId={1}".format(url, upload_session_id) json_request = json.dumps({"actions": actions}) parameters = {"headers": {"requestid": request_id}} @@ -355,7 +356,7 @@ def populate_permissions(self, item: DatasourceItem) -> None: self._permissions.populate(item) @api(version="2.0") - def update_permissions(self, item: DatasourceItem, permission_item: list["PermissionsRule"]) -> None: + def update_permissions(self, item: DatasourceItem, permission_item: List["PermissionsRule"]) -> None: self._permissions.update(item, permission_item) @api(version="2.0") @@ -389,12 +390,12 @@ def revisions_fetcher(): return self._get_datasource_revisions(datasource_item) datasource_item._set_revisions(revisions_fetcher) - logger.info(f"Populated revisions for datasource (ID: {datasource_item.id})") + logger.info("Populated revisions for datasource (ID: {0})".format(datasource_item.id)) def _get_datasource_revisions( self, datasource_item: DatasourceItem, req_options: Optional["RequestOptions"] = None - ) -> list[RevisionItem]: - url = f"{self.baseurl}/{datasource_item.id}/revisions" + ) -> List[RevisionItem]: + url = "{0}/{1}/revisions".format(self.baseurl, datasource_item.id) server_response = self.get_request(url, req_options) revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, datasource_item) return revisions @@ -412,9 +413,9 @@ def download_revision( error = "Datasource ID undefined." raise ValueError(error) if revision_number is None: - url = f"{self.baseurl}/{datasource_id}/content" + url = "{0}/{1}/content".format(self.baseurl, datasource_id) else: - url = f"{self.baseurl}/{datasource_id}/revisions/{revision_number}/content" + url = "{0}/{1}/revisions/{2}/content".format(self.baseurl, datasource_id, revision_number) if not include_extract: url += "?includeExtract=False" @@ -436,7 +437,9 @@ def download_revision( f.write(chunk) return_path = os.path.abspath(download_path) - logger.info(f"Downloaded datasource revision {revision_number} to {return_path} (ID: {datasource_id})") + logger.info( + "Downloaded datasource revision {0} to {1} (ID: {2})".format(revision_number, return_path, datasource_id) + ) return return_path @api(version="2.3") @@ -446,17 +449,19 @@ def delete_revision(self, datasource_id: str, revision_number: str) -> None: url = "/".join([self.baseurl, datasource_id, "revisions", revision_number]) self.delete_request(url) - logger.info(f"Deleted single datasource revision (ID: {datasource_id}) (Revision: {revision_number})") + logger.info( + "Deleted single datasource revision (ID: {0}) (Revision: {1})".format(datasource_id, revision_number) + ) # a convenience method @api(version="2.8") def schedule_extract_refresh( self, schedule_id: str, item: DatasourceItem - ) -> list["AddResponse"]: # actually should return a task + ) -> List["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, datasource=item) @api(version="1.0") - def add_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> set[str]: + def add_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> Set[str]: return super().add_tags(item, tags) @api(version="1.0") diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py index 499324e8e..19112d713 100644 --- a/tableauserverclient/server/endpoint/default_permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py @@ -4,8 +4,7 @@ from .exceptions import MissingRequiredFieldError from tableauserverclient.server import RequestFactory from tableauserverclient.models import DatabaseItem, PermissionsRule, ProjectItem, plural_type, Resource -from typing import TYPE_CHECKING, Callable, Optional, Union -from collections.abc import Sequence +from typing import TYPE_CHECKING, Callable, List, Optional, Sequence, Union if TYPE_CHECKING: from ..server import Server @@ -26,7 +25,7 @@ class _DefaultPermissionsEndpoint(Endpoint): """ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> None: - super().__init__(parent_srv) + super(_DefaultPermissionsEndpoint, self).__init__(parent_srv) # owner_baseurl is the baseurl of the parent, a project or database. # It MUST be a lambda since we don't know the full site URL until we sign in. @@ -34,25 +33,23 @@ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> No self.owner_baseurl = owner_baseurl def __str__(self): - return f"" + return "".format(self.owner_baseurl()) __repr__ = __str__ def update_default_permissions( - self, resource: BaseItem, permissions: Sequence[PermissionsRule], content_type: Union[Resource, str] - ) -> list[PermissionsRule]: - url = f"{self.owner_baseurl()}/{resource.id}/default-permissions/{plural_type(content_type)}" + self, resource: BaseItem, permissions: Sequence[PermissionsRule], content_type: Resource + ) -> List[PermissionsRule]: + url = "{0}/{1}/default-permissions/{2}".format(self.owner_baseurl(), resource.id, plural_type(content_type)) update_req = RequestFactory.Permission.add_req(permissions) response = self.put_request(url, update_req) permissions = PermissionsRule.from_response(response.content, self.parent_srv.namespace) - logger.info(f"Updated default {content_type} permissions for resource {resource.id}") + logger.info("Updated default {} permissions for resource {}".format(content_type, resource.id)) logger.info(permissions) return permissions - def delete_default_permission( - self, resource: BaseItem, rule: PermissionsRule, content_type: Union[Resource, str] - ) -> None: + def delete_default_permission(self, resource: BaseItem, rule: PermissionsRule, content_type: Resource) -> None: for capability, mode in rule.capabilities.items(): # Made readability better but line is too long, will make this look better url = ( @@ -68,27 +65,29 @@ def delete_default_permission( ) ) - logger.debug(f"Removing {mode} permission for capability {capability}") + logger.debug("Removing {0} permission for capability {1}".format(mode, capability)) self.delete_request(url) - logger.info(f"Deleted permission for {rule.grantee.tag_name} {rule.grantee.id} item {resource.id}") + logger.info( + "Deleted permission for {0} {1} item {2}".format(rule.grantee.tag_name, rule.grantee.id, resource.id) + ) - def populate_default_permissions(self, item: BaseItem, content_type: Union[Resource, str]) -> None: + def populate_default_permissions(self, item: BaseItem, content_type: Resource) -> None: if not item.id: error = "Server item is missing ID. Item must be retrieved from server first." raise MissingRequiredFieldError(error) - def permission_fetcher() -> list[PermissionsRule]: + def permission_fetcher() -> List[PermissionsRule]: return self._get_default_permissions(item, content_type) item._set_default_permissions(permission_fetcher, content_type) - logger.info(f"Populated default {content_type} permissions for item (ID: {item.id})") + logger.info("Populated default {0} permissions for item (ID: {1})".format(content_type, item.id)) def _get_default_permissions( - self, item: BaseItem, content_type: Union[Resource, str], req_options: Optional["RequestOptions"] = None - ) -> list[PermissionsRule]: - url = f"{self.owner_baseurl()}/{item.id}/default-permissions/{plural_type(content_type)}" + self, item: BaseItem, content_type: Resource, req_options: Optional["RequestOptions"] = None + ) -> List[PermissionsRule]: + url = "{0}/{1}/default-permissions/{2}".format(self.owner_baseurl(), item.id, plural_type(content_type)) server_response = self.get_request(url, req_options) permissions = PermissionsRule.from_response(server_response.content, self.parent_srv.namespace) logger.info({"content_type": content_type, "permissions": permissions}) diff --git a/tableauserverclient/server/endpoint/dqw_endpoint.py b/tableauserverclient/server/endpoint/dqw_endpoint.py index 90e31483b..5296523ee 100644 --- a/tableauserverclient/server/endpoint/dqw_endpoint.py +++ b/tableauserverclient/server/endpoint/dqw_endpoint.py @@ -10,35 +10,35 @@ class _DataQualityWarningEndpoint(Endpoint): def __init__(self, parent_srv, resource_type): - super().__init__(parent_srv) + super(_DataQualityWarningEndpoint, self).__init__(parent_srv) self.resource_type = resource_type @property def baseurl(self): - return "{}/sites/{}/dataQualityWarnings/{}".format( + return "{0}/sites/{1}/dataQualityWarnings/{2}".format( self.parent_srv.baseurl, self.parent_srv.site_id, self.resource_type ) def add(self, resource, warning): - url = f"{self.baseurl}/{resource.id}" + url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=resource.id) add_req = RequestFactory.DQW.add_req(warning) response = self.post_request(url, add_req) warnings = DQWItem.from_response(response.content, self.parent_srv.namespace) - logger.info(f"Added dqw for resource {resource.id}") + logger.info("Added dqw for resource {0}".format(resource.id)) return warnings def update(self, resource, warning): - url = f"{self.baseurl}/{resource.id}" + url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=resource.id) add_req = RequestFactory.DQW.update_req(warning) response = self.put_request(url, add_req) warnings = DQWItem.from_response(response.content, self.parent_srv.namespace) - logger.info(f"Added dqw for resource {resource.id}") + logger.info("Added dqw for resource {0}".format(resource.id)) return warnings def clear(self, resource): - url = f"{self.baseurl}/{resource.id}" + url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=resource.id) return self.delete_request(url) def populate(self, item): @@ -50,10 +50,10 @@ def dqw_fetcher(): return self._get_data_quality_warnings(item) item._set_data_quality_warnings(dqw_fetcher) - logger.info(f"Populated permissions for item (ID: {item.id})") + logger.info("Populated permissions for item (ID: {0})".format(item.id)) def _get_data_quality_warnings(self, item, req_options=None): - url = f"{self.baseurl}/{item.id}" + url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=item.id) server_response = self.get_request(url, req_options) dqws = DQWItem.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 9e1160705..be0602df5 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -8,9 +8,12 @@ from typing import ( Any, Callable, + Dict, Generic, + List, Optional, TYPE_CHECKING, + Tuple, TypeVar, Union, ) @@ -19,7 +22,6 @@ from tableauserverclient.server.request_options import RequestOptions from tableauserverclient.server.endpoint.exceptions import ( - FailedSignInError, ServerResponseError, InternalServerError, NonXMLResponseError, @@ -54,7 +56,7 @@ def __init__(self, parent_srv: "Server"): async_response = None @staticmethod - def set_parameters(http_options, auth_token, content, content_type, parameters) -> dict[str, Any]: + def set_parameters(http_options, auth_token, content, content_type, parameters) -> Dict[str, Any]: parameters = parameters or {} parameters.update(http_options) if "headers" not in parameters: @@ -80,7 +82,7 @@ def set_user_agent(parameters): else: # only set the TSC user agent if not already populated _client_version: Optional[str] = get_versions()["version"] - parameters["headers"][USER_AGENT_HEADER] = f"Tableau Server Client/{_client_version}" + parameters["headers"][USER_AGENT_HEADER] = "Tableau Server Client/{}".format(_client_version) # result: parameters["headers"]["User-Agent"] is set # return explicitly for testing only @@ -88,12 +90,12 @@ def set_user_agent(parameters): def _blocking_request(self, method, url, parameters={}) -> Optional[Union["Response", Exception]]: response = None - logger.debug(f"[{datetime.timestamp()}] Begin blocking request to {url}") + logger.debug("[{}] Begin blocking request to {}".format(datetime.timestamp(), url)) try: response = method(url, **parameters) - logger.debug(f"[{datetime.timestamp()}] Call finished") + logger.debug("[{}] Call finished".format(datetime.timestamp())) except Exception as e: - logger.debug(f"Error making request to server: {e}") + logger.debug("Error making request to server: {}".format(e)) raise e return response @@ -109,13 +111,13 @@ def _make_request( content: Optional[bytes] = None, auth_token: Optional[str] = None, content_type: Optional[str] = None, - parameters: Optional[dict[str, Any]] = None, + parameters: Optional[Dict[str, Any]] = None, ) -> "Response": parameters = Endpoint.set_parameters( self.parent_srv.http_options, auth_token, content, content_type, parameters ) - logger.debug(f"request method {method.__name__}, url: {url}") + logger.debug("request method {}, url: {}".format(method.__name__, url)) if content: redacted = helpers.strings.redact_xml(content[:200]) # this needs to be under a trace or something, it's a LOT @@ -127,21 +129,21 @@ def _make_request( server_response: Optional[Union["Response", Exception]] = self.send_request_while_show_progress_threaded( method, url, parameters, request_timeout ) - logger.debug(f"[{datetime.timestamp()}] Async request returned: received {server_response}") + logger.debug("[{}] Async request returned: received {}".format(datetime.timestamp(), server_response)) # is this blocking retry really necessary? I guess if it was just the threading messing it up? if server_response is None: logger.debug(server_response) - logger.debug(f"[{datetime.timestamp()}] Async request failed: retrying") + logger.debug("[{}] Async request failed: retrying".format(datetime.timestamp())) server_response = self._blocking_request(method, url, parameters) if server_response is None: - logger.debug(f"[{datetime.timestamp()}] Request failed") + logger.debug("[{}] Request failed".format(datetime.timestamp())) raise RuntimeError if isinstance(server_response, Exception): raise server_response self._check_status(server_response, url) loggable_response = self.log_response_safely(server_response) - logger.debug(f"Server response from {url}") + logger.debug("Server response from {0}".format(url)) # uncomment the following to log full responses in debug mode # BE CAREFUL WHEN SHARING THESE RESULTS - MAY CONTAIN YOUR SENSITIVE DATA # logger.debug(loggable_response) @@ -152,16 +154,16 @@ def _make_request( return server_response def _check_status(self, server_response: "Response", url: Optional[str] = None): - logger.debug(f"Response status: {server_response}") + logger.debug("Response status: {}".format(server_response)) if not hasattr(server_response, "status_code"): - raise OSError("Response is not a http response?") + raise EnvironmentError("Response is not a http response?") if server_response.status_code >= 500: raise InternalServerError(server_response, url) elif server_response.status_code not in Success_codes: try: if server_response.status_code == 401: # TODO: catch this in server.py and attempt to sign in again, in case it's a session expiry - raise FailedSignInError.from_response(server_response.content, self.parent_srv.namespace, url) + raise NotSignedInError(server_response.content, url) raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace, url) except ParseError: @@ -181,9 +183,9 @@ def log_response_safely(self, server_response: "Response") -> str: # content-type is an octet-stream accomplishes the same goal without eagerly loading content. # This check is to determine if the response is a text response (xml or otherwise) # so that we do not attempt to log bytes and other binary data. - loggable_response = f"Content type `{content_type}`" + loggable_response = "Content type `{}`".format(content_type) if content_type == "application/octet-stream": - loggable_response = f"A stream of type {content_type} [Truncated File Contents]" + loggable_response = "A stream of type {} [Truncated File Contents]".format(content_type) elif server_response.encoding and len(server_response.content) > 0: loggable_response = helpers.strings.redact_xml(server_response.content.decode(server_response.encoding)) return loggable_response @@ -311,7 +313,7 @@ def wrapper(self: E, *args: P.args, **kwargs: P.kwargs) -> R: for p in params_to_check: min_ver = Version(str(params[p])) if server_ver < min_ver: - error = f"{p!r} not available in {server_ver}, it will be ignored. Added in {min_ver}" + error = "{!r} not available in {}, it will be ignored. Added in {}".format(p, server_ver, min_ver) warnings.warn(error) return func(self, *args, **kwargs) @@ -351,5 +353,5 @@ def paginate(self, **kwargs) -> QuerySet[T]: return queryset @abc.abstractmethod - def get(self, request_options: Optional[RequestOptions] = None) -> tuple[list[T], PaginationItem]: + def get(self, request_options: Optional[RequestOptions] = None) -> Tuple[List[T], PaginationItem]: raise NotImplementedError(f".get has not been implemented for {self.__class__.__qualname__}") diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 77332da3e..9dfd38da6 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -1,31 +1,24 @@ from defusedxml.ElementTree import fromstring -from typing import Mapping, Optional, TypeVar - - -def split_pascal_case(s: str) -> str: - return "".join([f" {c}" if c.isupper() else c for c in s]).strip() +from typing import Optional class TableauError(Exception): pass -T = TypeVar("T") - - -class XMLError(TableauError): - def __init__(self, code: str, summary: str, detail: str, url: Optional[str] = None) -> None: +class ServerResponseError(TableauError): + def __init__(self, code, summary, detail, url=None): self.code = code self.summary = summary self.detail = detail self.url = url - super().__init__(str(self)) + super(ServerResponseError, self).__init__(str(self)) def __str__(self): - return f"\n\n\t{self.code}: {self.summary}\n\t\t{self.detail}" + return "\n\n\t{0}: {1}\n\t\t{2}".format(self.code, self.summary, self.detail) @classmethod - def from_response(cls, resp, ns, url): + def from_response(cls, resp, ns, url=None): # Check elements exist before .text parsed_response = fromstring(resp) try: @@ -40,10 +33,6 @@ def from_response(cls, resp, ns, url): return error_response -class ServerResponseError(XMLError): - pass - - class InternalServerError(TableauError): def __init__(self, server_response, request_url: Optional[str] = None): self.code = server_response.status_code @@ -51,7 +40,7 @@ def __init__(self, server_response, request_url: Optional[str] = None): self.url = request_url or "server" def __str__(self): - return f"\n\nInternal error {self.code} at {self.url}\n{self.content}" + return "\n\nInternal error {0} at {1}\n{2}".format(self.code, self.url, self.content) class MissingRequiredFieldError(TableauError): @@ -62,11 +51,6 @@ class NotSignedInError(TableauError): pass -class FailedSignInError(XMLError, NotSignedInError): - def __str__(self): - return f"{split_pascal_case(self.__class__.__name__)}: {super().__str__()}" - - class ItemTypeNotAllowed(TableauError): pass diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index 8330e6d2c..5f298f37e 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -20,13 +20,13 @@ class Favorites(Endpoint): @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/favorites" + return "{0}/sites/{1}/favorites".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Gets all favorites @api(version="2.5") def get(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: - logger.info(f"Querying all favorites for user {user_item.name}") - url = f"{self.baseurl}/{user_item.id}" + logger.info("Querying all favorites for user {0}".format(user_item.name)) + url = "{0}/{1}".format(self.baseurl, user_item.id) server_response = self.get_request(url, req_options) user_item._favorites = FavoriteItem.from_response(server_response.content, self.parent_srv.namespace) @@ -34,53 +34,53 @@ def get(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) @api(version="3.15") def add_favorite(self, user_item: UserItem, content_type: str, item: TableauItem) -> "Response": - url = f"{self.baseurl}/{user_item.id}" + url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_request(item.id, content_type, item.name) server_response = self.put_request(url, add_req) - logger.info(f"Favorited {item.name} for user (ID: {user_item.id})") + logger.info("Favorited {0} for user (ID: {1})".format(item.name, user_item.id)) return server_response @api(version="2.0") def add_favorite_workbook(self, user_item: UserItem, workbook_item: WorkbookItem) -> None: - url = f"{self.baseurl}/{user_item.id}" + url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_workbook_req(workbook_item.id, workbook_item.name) server_response = self.put_request(url, add_req) - logger.info(f"Favorited {workbook_item.name} for user (ID: {user_item.id})") + logger.info("Favorited {0} for user (ID: {1})".format(workbook_item.name, user_item.id)) @api(version="2.0") def add_favorite_view(self, user_item: UserItem, view_item: ViewItem) -> None: - url = f"{self.baseurl}/{user_item.id}" + url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_view_req(view_item.id, view_item.name) server_response = self.put_request(url, add_req) - logger.info(f"Favorited {view_item.name} for user (ID: {user_item.id})") + logger.info("Favorited {0} for user (ID: {1})".format(view_item.name, user_item.id)) @api(version="2.3") def add_favorite_datasource(self, user_item: UserItem, datasource_item: DatasourceItem) -> None: - url = f"{self.baseurl}/{user_item.id}" + url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_datasource_req(datasource_item.id, datasource_item.name) server_response = self.put_request(url, add_req) - logger.info(f"Favorited {datasource_item.name} for user (ID: {user_item.id})") + logger.info("Favorited {0} for user (ID: {1})".format(datasource_item.name, user_item.id)) @api(version="3.1") def add_favorite_project(self, user_item: UserItem, project_item: ProjectItem) -> None: - url = f"{self.baseurl}/{user_item.id}" + url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_project_req(project_item.id, project_item.name) server_response = self.put_request(url, add_req) - logger.info(f"Favorited {project_item.name} for user (ID: {user_item.id})") + logger.info("Favorited {0} for user (ID: {1})".format(project_item.name, user_item.id)) @api(version="3.3") def add_favorite_flow(self, user_item: UserItem, flow_item: FlowItem) -> None: - url = f"{self.baseurl}/{user_item.id}" + url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_flow_req(flow_item.id, flow_item.name) server_response = self.put_request(url, add_req) - logger.info(f"Favorited {flow_item.name} for user (ID: {user_item.id})") + logger.info("Favorited {0} for user (ID: {1})".format(flow_item.name, user_item.id)) @api(version="3.3") def add_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> None: - url = f"{self.baseurl}/{user_item.id}" + url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_request(metric_item.id, Resource.Metric, metric_item.name) server_response = self.put_request(url, add_req) - logger.info(f"Favorited metric {metric_item.name} for user (ID: {user_item.id})") + logger.info("Favorited metric {0} for user (ID: {1})".format(metric_item.name, user_item.id)) # ------- delete from favorites # Response: @@ -94,42 +94,42 @@ def add_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> N @api(version="3.15") def delete_favorite(self, user_item: UserItem, content_type: Resource, item: TableauItem) -> None: - url = f"{self.baseurl}/{user_item.id}/{content_type}/{item.id}" - logger.info(f"Removing favorite {content_type}({item.id}) for user (ID: {user_item.id})") + url = "{0}/{1}/{2}/{3}".format(self.baseurl, user_item.id, content_type, item.id) + logger.info("Removing favorite {0}({1}) for user (ID: {2})".format(content_type, item.id, user_item.id)) self.delete_request(url) @api(version="2.0") def delete_favorite_workbook(self, user_item: UserItem, workbook_item: WorkbookItem) -> None: - url = f"{self.baseurl}/{user_item.id}/workbooks/{workbook_item.id}" - logger.info(f"Removing favorite workbook {workbook_item.id} for user (ID: {user_item.id})") + url = "{0}/{1}/workbooks/{2}".format(self.baseurl, user_item.id, workbook_item.id) + logger.info("Removing favorite workbook {0} for user (ID: {1})".format(workbook_item.id, user_item.id)) self.delete_request(url) @api(version="2.0") def delete_favorite_view(self, user_item: UserItem, view_item: ViewItem) -> None: - url = f"{self.baseurl}/{user_item.id}/views/{view_item.id}" - logger.info(f"Removing favorite view {view_item.id} for user (ID: {user_item.id})") + url = "{0}/{1}/views/{2}".format(self.baseurl, user_item.id, view_item.id) + logger.info("Removing favorite view {0} for user (ID: {1})".format(view_item.id, user_item.id)) self.delete_request(url) @api(version="2.3") def delete_favorite_datasource(self, user_item: UserItem, datasource_item: DatasourceItem) -> None: - url = f"{self.baseurl}/{user_item.id}/datasources/{datasource_item.id}" - logger.info(f"Removing favorite {datasource_item.id} for user (ID: {user_item.id})") + url = "{0}/{1}/datasources/{2}".format(self.baseurl, user_item.id, datasource_item.id) + logger.info("Removing favorite {0} for user (ID: {1})".format(datasource_item.id, user_item.id)) self.delete_request(url) @api(version="3.1") def delete_favorite_project(self, user_item: UserItem, project_item: ProjectItem) -> None: - url = f"{self.baseurl}/{user_item.id}/projects/{project_item.id}" - logger.info(f"Removing favorite project {project_item.id} for user (ID: {user_item.id})") + url = "{0}/{1}/projects/{2}".format(self.baseurl, user_item.id, project_item.id) + logger.info("Removing favorite project {0} for user (ID: {1})".format(project_item.id, user_item.id)) self.delete_request(url) @api(version="3.3") def delete_favorite_flow(self, user_item: UserItem, flow_item: FlowItem) -> None: - url = f"{self.baseurl}/{user_item.id}/flows/{flow_item.id}" - logger.info(f"Removing favorite flow {flow_item.id} for user (ID: {user_item.id})") + url = "{0}/{1}/flows/{2}".format(self.baseurl, user_item.id, flow_item.id) + logger.info("Removing favorite flow {0} for user (ID: {1})".format(flow_item.id, user_item.id)) self.delete_request(url) @api(version="3.15") def delete_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> None: - url = f"{self.baseurl}/{user_item.id}/metrics/{metric_item.id}" - logger.info(f"Removing favorite metric {metric_item.id} for user (ID: {user_item.id})") + url = "{0}/{1}/metrics/{2}".format(self.baseurl, user_item.id, metric_item.id) + logger.info("Removing favorite metric {0} for user (ID: {1})".format(metric_item.id, user_item.id)) self.delete_request(url) diff --git a/tableauserverclient/server/endpoint/fileuploads_endpoint.py b/tableauserverclient/server/endpoint/fileuploads_endpoint.py index 1ae10e72d..0d30797c1 100644 --- a/tableauserverclient/server/endpoint/fileuploads_endpoint.py +++ b/tableauserverclient/server/endpoint/fileuploads_endpoint.py @@ -9,11 +9,11 @@ class Fileuploads(Endpoint): def __init__(self, parent_srv): - super().__init__(parent_srv) + super(Fileuploads, self).__init__(parent_srv) @property def baseurl(self): - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/fileUploads" + return "{0}/sites/{1}/fileUploads".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="2.0") def initiate(self): @@ -21,14 +21,14 @@ def initiate(self): server_response = self.post_request(url, "") fileupload_item = FileuploadItem.from_response(server_response.content, self.parent_srv.namespace) upload_id = fileupload_item.upload_session_id - logger.info(f"Initiated file upload session (ID: {upload_id})") + logger.info("Initiated file upload session (ID: {0})".format(upload_id)) return upload_id @api(version="2.0") def append(self, upload_id, data, content_type): - url = f"{self.baseurl}/{upload_id}" + url = "{0}/{1}".format(self.baseurl, upload_id) server_response = self.put_request(url, data, content_type) - logger.info(f"Uploading a chunk to session (ID: {upload_id})") + logger.info("Uploading a chunk to session (ID: {0})".format(upload_id)) return FileuploadItem.from_response(server_response.content, self.parent_srv.namespace) def _read_chunks(self, file): @@ -52,10 +52,12 @@ def _read_chunks(self, file): def upload(self, file): upload_id = self.initiate() for chunk in self._read_chunks(file): - logger.debug(f"{datetime.timestamp()} processing chunk...") + logger.debug("{} processing chunk...".format(datetime.timestamp())) request, content_type = RequestFactory.Fileupload.chunk_req(chunk) - logger.debug(f"{datetime.timestamp()} created chunk request") + logger.debug("{} created chunk request".format(datetime.timestamp())) fileupload_item = self.append(upload_id, request, content_type) - logger.info(f"\t{datetime.timestamp()} Published {(fileupload_item.file_size / BYTES_PER_MB)}MB") - logger.info(f"File upload finished (ID: {upload_id})") + logger.info( + "\t{0} Published {1}MB".format(datetime.timestamp(), (fileupload_item.file_size / BYTES_PER_MB)) + ) + logger.info("File upload finished (ID: {0})".format(upload_id)) return upload_id diff --git a/tableauserverclient/server/endpoint/flow_runs_endpoint.py b/tableauserverclient/server/endpoint/flow_runs_endpoint.py index 2c3bb84bc..c339a0645 100644 --- a/tableauserverclient/server/endpoint/flow_runs_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_runs_endpoint.py @@ -1,9 +1,9 @@ import logging -from typing import Optional, TYPE_CHECKING, Union +from typing import List, Optional, Tuple, TYPE_CHECKING from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api from tableauserverclient.server.endpoint.exceptions import FlowRunFailedException, FlowRunCancelledException -from tableauserverclient.models import FlowRunItem +from tableauserverclient.models import FlowRunItem, PaginationItem from tableauserverclient.exponential_backoff import ExponentialBackoffTimer from tableauserverclient.helpers.logging import logger @@ -16,24 +16,22 @@ class FlowRuns(QuerysetEndpoint[FlowRunItem]): def __init__(self, parent_srv: "Server") -> None: - super().__init__(parent_srv) + super(FlowRuns, self).__init__(parent_srv) return None @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/flows/runs" + return "{0}/sites/{1}/flows/runs".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Get all flows @api(version="3.10") - # QuerysetEndpoint expects a PaginationItem to be returned, but FlowRuns - # does not return a PaginationItem. Suppressing the mypy error because the - # changes to the QuerySet class should permit this to function regardless. - def get(self, req_options: Optional["RequestOptions"] = None) -> list[FlowRunItem]: # type: ignore[override] + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[FlowRunItem], PaginationItem]: logger.info("Querying all flow runs on site") url = self.baseurl server_response = self.get_request(url, req_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) all_flow_run_items = FlowRunItem.from_response(server_response.content, self.parent_srv.namespace) - return all_flow_run_items + return all_flow_run_items, pagination_item # Get 1 flow by id @api(version="3.10") @@ -41,21 +39,21 @@ def get_by_id(self, flow_run_id: str) -> FlowRunItem: if not flow_run_id: error = "Flow ID undefined." raise ValueError(error) - logger.info(f"Querying single flow (ID: {flow_run_id})") - url = f"{self.baseurl}/{flow_run_id}" + logger.info("Querying single flow (ID: {0})".format(flow_run_id)) + url = "{0}/{1}".format(self.baseurl, flow_run_id) server_response = self.get_request(url) return FlowRunItem.from_response(server_response.content, self.parent_srv.namespace)[0] # Cancel 1 flow run by id @api(version="3.10") - def cancel(self, flow_run_id: Union[str, FlowRunItem]) -> None: + def cancel(self, flow_run_id: str) -> None: if not flow_run_id: error = "Flow ID undefined." raise ValueError(error) id_ = getattr(flow_run_id, "id", flow_run_id) - url = f"{self.baseurl}/{id_}" + url = "{0}/{1}".format(self.baseurl, id_) self.put_request(url) - logger.info(f"Deleted single flow (ID: {id_})") + logger.info("Deleted single flow (ID: {0})".format(id_)) @api(version="3.10") def wait_for_job(self, flow_run_id: str, *, timeout: Optional[int] = None) -> FlowRunItem: @@ -71,7 +69,7 @@ def wait_for_job(self, flow_run_id: str, *, timeout: Optional[int] = None) -> Fl flow_run = self.get_by_id(flow_run_id) logger.debug(f"\tFlowRun {flow_run_id} progress={flow_run.progress}") - logger.info(f"FlowRun {flow_run_id} Completed: Status: {flow_run.status}") + logger.info("FlowRun {} Completed: Status: {}".format(flow_run_id, flow_run.status)) if flow_run.status == "Success": return flow_run diff --git a/tableauserverclient/server/endpoint/flow_task_endpoint.py b/tableauserverclient/server/endpoint/flow_task_endpoint.py index 9e21661e6..eea3f9710 100644 --- a/tableauserverclient/server/endpoint/flow_task_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_task_endpoint.py @@ -1,5 +1,5 @@ import logging -from typing import TYPE_CHECKING +from typing import List, Optional, Tuple, TYPE_CHECKING from tableauserverclient.server.endpoint.endpoint import Endpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError @@ -15,7 +15,7 @@ class FlowTasks(Endpoint): @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks/flows" + 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: diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 7eb5dc3ba..53d072f50 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -5,8 +5,7 @@ import os from contextlib import closing from pathlib import Path -from typing import Optional, TYPE_CHECKING, Union -from collections.abc import Iterable +from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union from tableauserverclient.helpers.headers import fix_filename @@ -54,18 +53,18 @@ class Flows(QuerysetEndpoint[FlowItem], TaggingMixin[FlowItem]): def __init__(self, parent_srv): - super().__init__(parent_srv) + super(Flows, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "flow") @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/flows" + return "{0}/sites/{1}/flows".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Get all flows @api(version="3.3") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[FlowItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[FlowItem], PaginationItem]: logger.info("Querying all flows on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -79,8 +78,8 @@ def get_by_id(self, flow_id: str) -> FlowItem: if not flow_id: error = "Flow ID undefined." raise ValueError(error) - logger.info(f"Querying single flow (ID: {flow_id})") - url = f"{self.baseurl}/{flow_id}" + logger.info("Querying single flow (ID: {0})".format(flow_id)) + url = "{0}/{1}".format(self.baseurl, flow_id) server_response = self.get_request(url) return FlowItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -95,10 +94,10 @@ def connections_fetcher(): return self._get_flow_connections(flow_item) flow_item._set_connections(connections_fetcher) - logger.info(f"Populated connections for flow (ID: {flow_item.id})") + logger.info("Populated connections for flow (ID: {0})".format(flow_item.id)) - def _get_flow_connections(self, flow_item, req_options: Optional["RequestOptions"] = None) -> list[ConnectionItem]: - url = f"{self.baseurl}/{flow_item.id}/connections" + def _get_flow_connections(self, flow_item, req_options: Optional["RequestOptions"] = None) -> List[ConnectionItem]: + url = "{0}/{1}/connections".format(self.baseurl, flow_item.id) server_response = self.get_request(url, req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) return connections @@ -109,9 +108,9 @@ def delete(self, flow_id: str) -> None: if not flow_id: error = "Flow ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{flow_id}" + url = "{0}/{1}".format(self.baseurl, flow_id) self.delete_request(url) - logger.info(f"Deleted single flow (ID: {flow_id})") + logger.info("Deleted single flow (ID: {0})".format(flow_id)) # Download 1 flow by id @api(version="3.3") @@ -119,7 +118,7 @@ def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> Path if not flow_id: error = "Flow ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{flow_id}/content" + url = "{0}/{1}/content".format(self.baseurl, flow_id) with closing(self.get_request(url, parameters={"stream": True})) as server_response: m = Message() @@ -138,7 +137,7 @@ def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> Path f.write(chunk) return_path = os.path.abspath(download_path) - logger.info(f"Downloaded flow to {return_path} (ID: {flow_id})") + logger.info("Downloaded flow to {0} (ID: {1})".format(return_path, flow_id)) return return_path # Update flow @@ -151,28 +150,28 @@ def update(self, flow_item: FlowItem) -> FlowItem: self._resource_tagger.update_tags(self.baseurl, flow_item) # Update the flow itself - url = f"{self.baseurl}/{flow_item.id}" + url = "{0}/{1}".format(self.baseurl, flow_item.id) update_req = RequestFactory.Flow.update_req(flow_item) server_response = self.put_request(url, update_req) - logger.info(f"Updated flow item (ID: {flow_item.id})") + logger.info("Updated flow item (ID: {0})".format(flow_item.id)) updated_flow = copy.copy(flow_item) return updated_flow._parse_common_elements(server_response.content, self.parent_srv.namespace) # Update flow connections @api(version="3.3") def update_connection(self, flow_item: FlowItem, connection_item: ConnectionItem) -> ConnectionItem: - url = f"{self.baseurl}/{flow_item.id}/connections/{connection_item.id}" + url = "{0}/{1}/connections/{2}".format(self.baseurl, flow_item.id, connection_item.id) update_req = RequestFactory.Connection.update_req(connection_item) server_response = self.put_request(url, update_req) connection = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Updated flow item (ID: {flow_item.id} & connection item {connection_item.id}") + logger.info("Updated flow item (ID: {0} & connection item {1}".format(flow_item.id, connection_item.id)) return connection @api(version="3.3") def refresh(self, flow_item: FlowItem) -> JobItem: - url = f"{self.baseurl}/{flow_item.id}/run" + url = "{0}/{1}/run".format(self.baseurl, flow_item.id) empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -181,7 +180,7 @@ def refresh(self, flow_item: FlowItem) -> JobItem: # Publish flow @api(version="3.3") def publish( - self, flow_item: FlowItem, file: PathOrFileR, mode: str, connections: Optional[list[ConnectionItem]] = None + self, flow_item: FlowItem, file: PathOrFileR, mode: str, connections: Optional[List[ConnectionItem]] = None ) -> FlowItem: if not mode or not hasattr(self.parent_srv.PublishMode, mode): error = "Invalid mode defined." @@ -190,7 +189,7 @@ def publish( if isinstance(file, (str, os.PathLike)): if not os.path.isfile(file): error = "File path does not lead to an existing file." - raise OSError(error) + raise IOError(error) filename = os.path.basename(file) file_extension = os.path.splitext(filename)[1][1:] @@ -214,30 +213,30 @@ def publish( elif file_type == "xml": file_extension = "tfl" else: - error = f"Unsupported file type {file_type}!" + error = "Unsupported file type {}!".format(file_type) raise ValueError(error) # Generate filename for file object. # This is needed when publishing the flow in a single request - filename = f"{flow_item.name}.{file_extension}" + filename = "{}.{}".format(flow_item.name, file_extension) file_size = get_file_object_size(file) else: raise TypeError("file should be a filepath or file object.") # Construct the url with the defined mode - url = f"{self.baseurl}?flowType={file_extension}" + url = "{0}?flowType={1}".format(self.baseurl, file_extension) if mode == self.parent_srv.PublishMode.Overwrite or mode == self.parent_srv.PublishMode.Append: - url += f"&{mode.lower()}=true" + url += "&{0}=true".format(mode.lower()) # Determine if chunking is required (64MB is the limit for single upload method) if file_size >= FILESIZE_LIMIT: - logger.info(f"Publishing {filename} to server with chunking method (flow over 64MB)") + logger.info("Publishing {0} to server with chunking method (flow over 64MB)".format(filename)) upload_session_id = self.parent_srv.fileuploads.upload(file) - url = f"{url}&uploadSessionId={upload_session_id}" + url = "{0}&uploadSessionId={1}".format(url, upload_session_id) xml_request, content_type = RequestFactory.Flow.publish_req_chunked(flow_item, connections) else: - logger.info(f"Publishing {filename} to server") + logger.info("Publishing {0} to server".format(filename)) if isinstance(file, (str, Path)): with open(file, "rb") as f: @@ -260,7 +259,7 @@ def publish( raise err else: new_flow = FlowItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Published {filename} (ID: {new_flow.id})") + logger.info("Published {0} (ID: {1})".format(filename, new_flow.id)) return new_flow @api(version="3.3") @@ -295,7 +294,7 @@ def delete_dqw(self, item: FlowItem) -> None: @api(version="3.3") def schedule_flow_run( self, schedule_id: str, item: FlowItem - ) -> list["AddResponse"]: # actually should return a task + ) -> List["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, flow=item) def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[FlowItem]: diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index c512b011b..8acf31692 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -8,8 +8,7 @@ from tableauserverclient.helpers.logging import logger -from typing import Optional, TYPE_CHECKING, Union -from collections.abc import Iterable +from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union from tableauserverclient.server.query import QuerySet @@ -20,10 +19,10 @@ class Groups(QuerysetEndpoint[GroupItem]): @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/groups" + return "{0}/sites/{1}/groups".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="2.0") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[GroupItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[GroupItem], PaginationItem]: """Gets all groups""" logger.info("Querying all groups on site") url = self.baseurl @@ -51,12 +50,12 @@ def user_pager(): def _get_users_for_group( self, group_item: GroupItem, req_options: Optional["RequestOptions"] = None - ) -> tuple[list[UserItem], PaginationItem]: - url = f"{self.baseurl}/{group_item.id}/users" + ) -> Tuple[List[UserItem], PaginationItem]: + url = "{0}/{1}/users".format(self.baseurl, group_item.id) server_response = self.get_request(url, req_options) user_item = UserItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) - logger.info(f"Populated users for group (ID: {group_item.id})") + logger.info("Populated users for group (ID: {0})".format(group_item.id)) return user_item, pagination_item @api(version="2.0") @@ -65,13 +64,13 @@ def delete(self, group_id: str) -> None: if not group_id: error = "Group ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{group_id}" + url = "{0}/{1}".format(self.baseurl, group_id) self.delete_request(url) - logger.info(f"Deleted single group (ID: {group_id})") + 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]: - url = f"{self.baseurl}/{group_item.id}" + url = "{0}/{1}".format(self.baseurl, group_item.id) if not group_item.id: error = "Group item missing ID." @@ -84,7 +83,7 @@ def update(self, group_item: GroupItem, as_job: bool = False) -> Union[GroupItem update_req = RequestFactory.Group.update_req(group_item) server_response = self.put_request(url, update_req) - logger.info(f"Updated group item (ID: {group_item.id})") + logger.info("Updated group item (ID: {0})".format(group_item.id)) if as_job: return JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] else: @@ -119,9 +118,9 @@ def remove_user(self, group_item: GroupItem, user_id: str) -> None: if not user_id: error = "User ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{group_item.id}/users/{user_id}" + url = "{0}/{1}/users/{2}".format(self.baseurl, group_item.id, user_id) self.delete_request(url) - logger.info(f"Removed user (id: {user_id}) from group (ID: {group_item.id})") + logger.info("Removed user (id: {0}) from group (ID: {1})".format(user_id, group_item.id)) @api(version="3.21") def remove_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> None: @@ -133,7 +132,7 @@ def remove_users(self, group_item: GroupItem, users: Iterable[Union[str, UserIte url = f"{self.baseurl}/{group_id}/users/remove" add_req = RequestFactory.Group.remove_users_req(users) _ = self.put_request(url, add_req) - logger.info(f"Removed users to group (ID: {group_item.id})") + logger.info("Removed users to group (ID: {0})".format(group_item.id)) return None @api(version="2.0") @@ -145,15 +144,15 @@ def add_user(self, group_item: GroupItem, user_id: str) -> UserItem: if not user_id: error = "User ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{group_item.id}/users" + url = "{0}/{1}/users".format(self.baseurl, group_item.id) add_req = RequestFactory.Group.add_user_req(user_id) server_response = self.post_request(url, add_req) user = UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() - logger.info(f"Added user (id: {user_id}) to group (ID: {group_item.id})") + logger.info("Added user (id: {0}) to group (ID: {1})".format(user_id, group_item.id)) return user @api(version="3.21") - def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> list[UserItem]: + def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> List[UserItem]: """Adds multiple users to 1 group""" group_id = group_item.id if hasattr(group_item, "id") else group_item if not isinstance(group_id, str): @@ -163,7 +162,7 @@ def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]] add_req = RequestFactory.Group.add_users_req(users) server_response = self.post_request(url, add_req) users = UserItem.from_response(server_response.content, self.parent_srv.namespace) - logger.info(f"Added users to group (ID: {group_item.id})") + logger.info("Added users to group (ID: {0})".format(group_item.id)) return users def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[GroupItem]: diff --git a/tableauserverclient/server/endpoint/groupsets_endpoint.py b/tableauserverclient/server/endpoint/groupsets_endpoint.py index c7f5ed0e5..06e7cc627 100644 --- a/tableauserverclient/server/endpoint/groupsets_endpoint.py +++ b/tableauserverclient/server/endpoint/groupsets_endpoint.py @@ -1,4 +1,4 @@ -from typing import Literal, Optional, TYPE_CHECKING, Union +from typing import List, Literal, Optional, Tuple, TYPE_CHECKING, Union from tableauserverclient.helpers.logging import logger from tableauserverclient.models.group_item import GroupItem @@ -27,7 +27,7 @@ def get( self, request_options: Optional[RequestOptions] = None, result_level: Optional[Literal["members", "local"]] = None, - ) -> tuple[list[GroupSetItem], PaginationItem]: + ) -> Tuple[List[GroupSetItem], PaginationItem]: logger.info("Querying all group sets on site") url = self.baseurl if result_level: diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index 723d3dd38..ae8cf2633 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -11,24 +11,24 @@ from tableauserverclient.helpers.logging import logger -from typing import Optional, Union +from typing import List, Optional, Tuple, Union class Jobs(QuerysetEndpoint[BackgroundJobItem]): @property def baseurl(self): - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/jobs" + return "{0}/sites/{1}/jobs".format(self.parent_srv.baseurl, self.parent_srv.site_id) @overload # type: ignore[override] def get(self: Self, job_id: str, req_options: Optional[RequestOptionsBase] = None) -> JobItem: # type: ignore[override] ... @overload # type: ignore[override] - def get(self: Self, job_id: RequestOptionsBase, req_options: None) -> tuple[list[BackgroundJobItem], PaginationItem]: # type: ignore[override] + def get(self: Self, job_id: RequestOptionsBase, req_options: None) -> Tuple[List[BackgroundJobItem], PaginationItem]: # type: ignore[override] ... @overload # type: ignore[override] - def get(self: Self, job_id: None, req_options: Optional[RequestOptionsBase]) -> tuple[list[BackgroundJobItem], PaginationItem]: # type: ignore[override] + def get(self: Self, job_id: None, req_options: Optional[RequestOptionsBase]) -> Tuple[List[BackgroundJobItem], PaginationItem]: # type: ignore[override] ... @api(version="2.6") @@ -53,13 +53,13 @@ def cancel(self, job_id: Union[str, JobItem]): if isinstance(job_id, JobItem): job_id = job_id.id assert isinstance(job_id, str) - url = f"{self.baseurl}/{job_id}" + url = "{0}/{1}".format(self.baseurl, job_id) return self.put_request(url) @api(version="2.6") def get_by_id(self, job_id: str) -> JobItem: logger.info("Query for information about job " + job_id) - url = f"{self.baseurl}/{job_id}" + url = "{0}/{1}".format(self.baseurl, job_id) server_response = self.get_request(url) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job @@ -77,7 +77,7 @@ def wait_for_job(self, job_id: Union[str, JobItem], *, timeout: Optional[float] job = self.get_by_id(job_id) logger.debug(f"\tJob {job_id} progress={job.progress}") - logger.info(f"Job {job_id} Completed: Finish Code: {job.finish_code} - Notes:{job.notes}") + logger.info("Job {} Completed: Finish Code: {} - Notes:{}".format(job_id, job.finish_code, job.notes)) if job.finish_code == JobItem.FinishCode.Success: return job diff --git a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py index ede4d38e3..374130509 100644 --- a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py @@ -1,4 +1,4 @@ -from typing import Optional, Union +from typing import List, Optional, Tuple, Union from tableauserverclient.helpers.logging import logger from tableauserverclient.models.linked_tasks_item import LinkedTaskItem, LinkedTaskJobItem @@ -18,7 +18,7 @@ def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks/linked" @api(version="3.15") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[LinkedTaskItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[LinkedTaskItem], PaginationItem]: logger.info("Querying all linked tasks on site") url = self.baseurl server_response = self.get_request(url, req_options) diff --git a/tableauserverclient/server/endpoint/metadata_endpoint.py b/tableauserverclient/server/endpoint/metadata_endpoint.py index e5dbcbcf8..38c3eebb6 100644 --- a/tableauserverclient/server/endpoint/metadata_endpoint.py +++ b/tableauserverclient/server/endpoint/metadata_endpoint.py @@ -50,11 +50,11 @@ def get_page_info(result): class Metadata(Endpoint): @property def baseurl(self): - return f"{self.parent_srv.server_address}/api/metadata/graphql" + return "{0}/api/metadata/graphql".format(self.parent_srv.server_address) @property def control_baseurl(self): - return f"{self.parent_srv.server_address}/api/metadata/v1/control" + return "{0}/api/metadata/v1/control".format(self.parent_srv.server_address) @api("3.5") def query(self, query, variables=None, abort_on_error=False, parameters=None): diff --git a/tableauserverclient/server/endpoint/metrics_endpoint.py b/tableauserverclient/server/endpoint/metrics_endpoint.py index 3fea1f5b6..ab1ec5852 100644 --- a/tableauserverclient/server/endpoint/metrics_endpoint.py +++ b/tableauserverclient/server/endpoint/metrics_endpoint.py @@ -8,7 +8,7 @@ import logging -from typing import Optional, TYPE_CHECKING +from typing import List, Optional, TYPE_CHECKING, Tuple if TYPE_CHECKING: from ..request_options import RequestOptions @@ -20,18 +20,18 @@ class Metrics(QuerysetEndpoint[MetricItem]): def __init__(self, parent_srv: "Server") -> None: - super().__init__(parent_srv) + super(Metrics, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "metric") @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/metrics" + return "{0}/sites/{1}/metrics".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Get all metrics @api(version="3.9") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[MetricItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[MetricItem], PaginationItem]: logger.info("Querying all metrics on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -45,8 +45,8 @@ def get_by_id(self, metric_id: str) -> MetricItem: if not metric_id: error = "Metric ID undefined." raise ValueError(error) - logger.info(f"Querying single metric (ID: {metric_id})") - url = f"{self.baseurl}/{metric_id}" + logger.info("Querying single metric (ID: {0})".format(metric_id)) + url = "{0}/{1}".format(self.baseurl, metric_id) server_response = self.get_request(url) return MetricItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -56,9 +56,9 @@ def delete(self, metric_id: str) -> None: if not metric_id: error = "Metric ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{metric_id}" + url = "{0}/{1}".format(self.baseurl, metric_id) self.delete_request(url) - logger.info(f"Deleted single metric (ID: {metric_id})") + logger.info("Deleted single metric (ID: {0})".format(metric_id)) # Update metric @api(version="3.9") @@ -70,8 +70,8 @@ def update(self, metric_item: MetricItem) -> MetricItem: self._resource_tagger.update_tags(self.baseurl, metric_item) # Update the metric itself - url = f"{self.baseurl}/{metric_item.id}" + url = "{0}/{1}".format(self.baseurl, metric_item.id) update_req = RequestFactory.Metric.update_req(metric_item) server_response = self.put_request(url, update_req) - logger.info(f"Updated metric item (ID: {metric_item.id})") + logger.info("Updated metric item (ID: {0})".format(metric_item.id)) return MetricItem.from_response(server_response.content, self.parent_srv.namespace)[0] diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py index 10d420ff7..4433625f2 100644 --- a/tableauserverclient/server/endpoint/permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/permissions_endpoint.py @@ -6,7 +6,7 @@ from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError -from typing import Callable, TYPE_CHECKING, Optional, Union +from typing import Callable, TYPE_CHECKING, List, Optional, Union from tableauserverclient.helpers.logging import logger @@ -25,7 +25,7 @@ class _PermissionsEndpoint(Endpoint): """ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> None: - super().__init__(parent_srv) + super(_PermissionsEndpoint, self).__init__(parent_srv) # owner_baseurl is the baseurl of the parent. The MUST be a lambda # since we don't know the full site URL until we sign in. If @@ -33,18 +33,18 @@ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> No self.owner_baseurl = owner_baseurl def __str__(self): - return f"" + return "".format(self.owner_baseurl) - def update(self, resource: TableauItem, permissions: list[PermissionsRule]) -> list[PermissionsRule]: - url = f"{self.owner_baseurl()}/{resource.id}/permissions" + def update(self, resource: TableauItem, permissions: List[PermissionsRule]) -> List[PermissionsRule]: + url = "{0}/{1}/permissions".format(self.owner_baseurl(), resource.id) update_req = RequestFactory.Permission.add_req(permissions) response = self.put_request(url, update_req) permissions = PermissionsRule.from_response(response.content, self.parent_srv.namespace) - logger.info(f"Updated permissions for resource {resource.id}: {permissions}") + logger.info("Updated permissions for resource {0}: {1}".format(resource.id, permissions)) return permissions - def delete(self, resource: TableauItem, rules: Union[PermissionsRule, list[PermissionsRule]]): + def delete(self, resource: TableauItem, rules: Union[PermissionsRule, List[PermissionsRule]]): # Delete is the only endpoint that doesn't take a list of rules # so let's fake it to keep it consistent # TODO that means we need error handling around the call @@ -54,7 +54,7 @@ def delete(self, resource: TableauItem, rules: Union[PermissionsRule, list[Permi for rule in rules: for capability, mode in rule.capabilities.items(): "/permissions/groups/group-id/capability-name/capability-mode" - url = "{}/{}/permissions/{}/{}/{}/{}".format( + url = "{0}/{1}/permissions/{2}/{3}/{4}/{5}".format( self.owner_baseurl(), resource.id, rule.grantee.tag_name + "s", @@ -63,11 +63,13 @@ def delete(self, resource: TableauItem, rules: Union[PermissionsRule, list[Permi mode, ) - logger.debug(f"Removing {mode} permission for capability {capability}") + logger.debug("Removing {0} permission for capability {1}".format(mode, capability)) self.delete_request(url) - logger.info(f"Deleted permission for {rule.grantee.tag_name} {rule.grantee.id} item {resource.id}") + logger.info( + "Deleted permission for {0} {1} item {2}".format(rule.grantee.tag_name, rule.grantee.id, resource.id) + ) def populate(self, item: TableauItem): if not item.id: @@ -78,12 +80,12 @@ def permission_fetcher(): return self._get_permissions(item) item._set_permissions(permission_fetcher) - logger.info(f"Populated permissions for item (ID: {item.id})") + logger.info("Populated permissions for item (ID: {0})".format(item.id)) def _get_permissions(self, item: TableauItem, req_options: Optional["RequestOptions"] = None): - url = f"{self.owner_baseurl()}/{item.id}/permissions" + url = "{0}/{1}/permissions".format(self.owner_baseurl(), item.id) server_response = self.get_request(url, req_options) permissions = PermissionsRule.from_response(server_response.content, self.parent_srv.namespace) - logger.info(f"Permissions for resource {item.id}: {permissions}") + logger.info("Permissions for resource {0}: {1}".format(item.id, permissions)) return permissions diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 74bb865c7..565817e37 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -5,10 +5,9 @@ from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint from tableauserverclient.server import RequestFactory, RequestOptions -from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.models import ProjectItem, PaginationItem, Resource -from typing import Optional, TYPE_CHECKING +from typing import List, Optional, Tuple, TYPE_CHECKING from tableauserverclient.server.query import QuerySet @@ -21,17 +20,17 @@ class Projects(QuerysetEndpoint[ProjectItem]): def __init__(self, parent_srv: "Server") -> None: - super().__init__(parent_srv) + super(Projects, self).__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl) @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/projects" + return "{0}/sites/{1}/projects".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="2.0") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[ProjectItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[ProjectItem], PaginationItem]: logger.info("Querying all projects on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -44,9 +43,9 @@ def delete(self, project_id: str) -> None: if not project_id: error = "Project ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{project_id}" + url = "{0}/{1}".format(self.baseurl, project_id) self.delete_request(url) - logger.info(f"Deleted single project (ID: {project_id})") + logger.info("Deleted single project (ID: {0})".format(project_id)) @api(version="2.0") def update(self, project_item: ProjectItem, samples: bool = False) -> ProjectItem: @@ -55,10 +54,10 @@ def update(self, project_item: ProjectItem, samples: bool = False) -> ProjectIte raise MissingRequiredFieldError(error) params = {"params": {RequestOptions.Field.PublishSamples: samples}} - url = f"{self.baseurl}/{project_item.id}" + url = "{0}/{1}".format(self.baseurl, project_item.id) update_req = RequestFactory.Project.update_req(project_item) server_response = self.put_request(url, update_req, XML_CONTENT_TYPE, params) - logger.info(f"Updated project item (ID: {project_item.id})") + logger.info("Updated project item (ID: {0})".format(project_item.id)) updated_project = ProjectItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_project @@ -67,11 +66,11 @@ def create(self, project_item: ProjectItem, samples: bool = False) -> ProjectIte params = {"params": {RequestOptions.Field.PublishSamples: samples}} url = self.baseurl if project_item._samples: - url = f"{self.baseurl}?publishSamples={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] - logger.info(f"Created new project (ID: {new_project.id})") + logger.info("Created new project (ID: {0})".format(new_project.id)) return new_project @api(version="2.0") @@ -79,135 +78,85 @@ def populate_permissions(self, item: ProjectItem) -> None: self._permissions.populate(item) @api(version="2.0") - def update_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: + def update_permissions(self, item, rules): return self._permissions.update(item, rules) @api(version="2.0") - def delete_permission(self, item: ProjectItem, rules: list[PermissionsRule]) -> None: + def delete_permission(self, item, rules): self._permissions.delete(item, rules) @api(version="2.1") - def populate_workbook_default_permissions(self, item: ProjectItem) -> None: + def populate_workbook_default_permissions(self, item): self._default_permissions.populate_default_permissions(item, Resource.Workbook) @api(version="2.1") - def populate_datasource_default_permissions(self, item: ProjectItem) -> None: + def populate_datasource_default_permissions(self, item): self._default_permissions.populate_default_permissions(item, Resource.Datasource) @api(version="3.2") - def populate_metric_default_permissions(self, item: ProjectItem) -> None: + def populate_metric_default_permissions(self, item): self._default_permissions.populate_default_permissions(item, Resource.Metric) @api(version="3.4") - def populate_datarole_default_permissions(self, item: ProjectItem) -> None: + def populate_datarole_default_permissions(self, item): self._default_permissions.populate_default_permissions(item, Resource.Datarole) @api(version="3.4") - def populate_flow_default_permissions(self, item: ProjectItem) -> None: + def populate_flow_default_permissions(self, item): self._default_permissions.populate_default_permissions(item, Resource.Flow) @api(version="3.4") - def populate_lens_default_permissions(self, item: ProjectItem) -> None: + def populate_lens_default_permissions(self, item): self._default_permissions.populate_default_permissions(item, Resource.Lens) - @api(version="3.23") - def populate_virtualconnection_default_permissions(self, item: ProjectItem) -> None: - self._default_permissions.populate_default_permissions(item, Resource.VirtualConnection) - - @api(version="3.23") - def populate_database_default_permissions(self, item: ProjectItem) -> None: - self._default_permissions.populate_default_permissions(item, Resource.Database) - - @api(version="3.23") - def populate_table_default_permissions(self, item: ProjectItem) -> None: - self._default_permissions.populate_default_permissions(item, Resource.Table) - @api(version="2.1") - def update_workbook_default_permissions( - self, item: ProjectItem, rules: list[PermissionsRule] - ) -> list[PermissionsRule]: + def update_workbook_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Resource.Workbook) @api(version="2.1") - def update_datasource_default_permissions( - self, item: ProjectItem, rules: list[PermissionsRule] - ) -> list[PermissionsRule]: + def update_datasource_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Resource.Datasource) @api(version="3.2") - def update_metric_default_permissions( - self, item: ProjectItem, rules: list[PermissionsRule] - ) -> list[PermissionsRule]: + def update_metric_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Resource.Metric) @api(version="3.4") - def update_datarole_default_permissions( - self, item: ProjectItem, rules: list[PermissionsRule] - ) -> list[PermissionsRule]: + def update_datarole_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Resource.Datarole) @api(version="3.4") - def update_flow_default_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: + def update_flow_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Resource.Flow) @api(version="3.4") - def update_lens_default_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: + def update_lens_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Resource.Lens) - @api(version="3.23") - def update_virtualconnection_default_permissions( - self, item: ProjectItem, rules: list[PermissionsRule] - ) -> list[PermissionsRule]: - return self._default_permissions.update_default_permissions(item, rules, Resource.VirtualConnection) - - @api(version="3.23") - def update_database_default_permissions( - self, item: ProjectItem, rules: list[PermissionsRule] - ) -> list[PermissionsRule]: - return self._default_permissions.update_default_permissions(item, rules, Resource.Database) - - @api(version="3.23") - def update_table_default_permissions( - self, item: ProjectItem, rules: list[PermissionsRule] - ) -> list[PermissionsRule]: - return self._default_permissions.update_default_permissions(item, rules, Resource.Table) - @api(version="2.1") - def delete_workbook_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + def delete_workbook_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Resource.Workbook) @api(version="2.1") - def delete_datasource_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + def delete_datasource_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Resource.Datasource) @api(version="3.2") - def delete_metric_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + def delete_metric_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Resource.Metric) @api(version="3.4") - def delete_datarole_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + def delete_datarole_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Resource.Datarole) @api(version="3.4") - def delete_flow_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + def delete_flow_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Resource.Flow) @api(version="3.4") - def delete_lens_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + def delete_lens_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Resource.Lens) - @api(version="3.23") - def delete_virtualconnection_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: - self._default_permissions.delete_default_permission(item, rule, Resource.VirtualConnection) - - @api(version="3.23") - def delete_database_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: - self._default_permissions.delete_default_permission(item, rule, Resource.Database) - - @api(version="3.23") - def delete_table_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: - self._default_permissions.delete_default_permission(item, rule, Resource.Table) - def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[ProjectItem]: """ Queries the Tableau Server for items using the specified filters. Page diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index 63c03b3e3..1894e3b8a 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -1,7 +1,6 @@ import abc import copy -from typing import Generic, Optional, Protocol, TypeVar, Union, TYPE_CHECKING, runtime_checkable -from collections.abc import Iterable +from typing import Generic, Iterable, Optional, Protocol, Set, TypeVar, Union, TYPE_CHECKING, runtime_checkable import urllib.parse from tableauserverclient.server.endpoint.endpoint import Endpoint, api @@ -25,7 +24,7 @@ class _ResourceTagger(Endpoint): # Add new tags to resource def _add_tags(self, baseurl, resource_id, tag_set): - url = f"{baseurl}/{resource_id}/tags" + url = "{0}/{1}/tags".format(baseurl, resource_id) add_req = RequestFactory.Tag.add_req(tag_set) try: @@ -40,7 +39,7 @@ def _add_tags(self, baseurl, resource_id, tag_set): # Delete a resource's tag by name def _delete_tag(self, baseurl, resource_id, tag_name): encoded_tag_name = urllib.parse.quote(tag_name) - url = f"{baseurl}/{resource_id}/tags/{encoded_tag_name}" + url = "{0}/{1}/tags/{2}".format(baseurl, resource_id, encoded_tag_name) try: self.delete_request(url) @@ -60,7 +59,7 @@ def update_tags(self, baseurl, resource_item): if add_set: resource_item.tags = self._add_tags(baseurl, resource_item.id, add_set) resource_item._initial_tags = copy.copy(resource_item.tags) - logger.info(f"Updated tags to {resource_item.tags}") + logger.info("Updated tags to {0}".format(resource_item.tags)) class Response(Protocol): @@ -69,8 +68,8 @@ class Response(Protocol): @runtime_checkable class Taggable(Protocol): - tags: set[str] - _initial_tags: set[str] + tags: Set[str] + _initial_tags: Set[str] @property def id(self) -> Optional[str]: @@ -96,14 +95,14 @@ def put_request(self, url, request) -> Response: def delete_request(self, url) -> None: pass - def add_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> set[str]: + def add_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> Set[str]: item_id = getattr(item, "id", item) if not isinstance(item_id, str): raise ValueError("ID not found.") if isinstance(tags, str): - tag_set = {tags} + tag_set = set([tags]) else: tag_set = set(tags) @@ -119,7 +118,7 @@ def delete_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> N raise ValueError("ID not found.") if isinstance(tags, str): - tag_set = {tags} + tag_set = set([tags]) else: tag_set = set(tags) @@ -159,9 +158,9 @@ def baseurl(self): return f"{self.parent_srv.baseurl}/tags" @api(version="3.9") - def batch_add(self, tags: Union[Iterable[str], str], content: content) -> set[str]: + def batch_add(self, tags: Union[Iterable[str], str], content: content) -> Set[str]: if isinstance(tags, str): - tag_set = {tags} + tag_set = set([tags]) else: tag_set = set(tags) @@ -171,9 +170,9 @@ def batch_add(self, tags: Union[Iterable[str], str], content: content) -> set[st return TagItem.from_response(server_response.content, self.parent_srv.namespace) @api(version="3.9") - def batch_delete(self, tags: Union[Iterable[str], str], content: content) -> set[str]: + def batch_delete(self, tags: Union[Iterable[str], str], content: content) -> Set[str]: if isinstance(tags, str): - tag_set = {tags} + tag_set = set([tags]) else: tag_set = set(tags) diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index eec4536f9..cfaee3324 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -2,7 +2,7 @@ import logging import warnings from collections import namedtuple -from typing import TYPE_CHECKING, Callable, Optional, Union +from typing import TYPE_CHECKING, Callable, List, Optional, Tuple, Union from .endpoint import Endpoint, api, parameter_added_in from .exceptions import MissingRequiredFieldError @@ -22,14 +22,14 @@ class Schedules(Endpoint): @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/schedules" + return "{0}/schedules".format(self.parent_srv.baseurl) @property def siteurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/schedules" + return "{0}/sites/{1}/schedules".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="2.3") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[ScheduleItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[ScheduleItem], PaginationItem]: logger.info("Querying all schedules") url = self.baseurl server_response = self.get_request(url, req_options) @@ -42,8 +42,8 @@ def get_by_id(self, schedule_id): if not schedule_id: error = "No Schedule ID provided" raise ValueError(error) - logger.info(f"Querying a single schedule by id ({schedule_id})") - url = f"{self.baseurl}/{schedule_id}" + logger.info("Querying a single schedule by id ({})".format(schedule_id)) + url = "{0}/{1}".format(self.baseurl, schedule_id) server_response = self.get_request(url) return ScheduleItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -52,9 +52,9 @@ def delete(self, schedule_id: str) -> None: if not schedule_id: error = "Schedule ID undefined" raise ValueError(error) - url = f"{self.baseurl}/{schedule_id}" + url = "{0}/{1}".format(self.baseurl, schedule_id) self.delete_request(url) - logger.info(f"Deleted single schedule (ID: {schedule_id})") + logger.info("Deleted single schedule (ID: {0})".format(schedule_id)) @api(version="2.3") def update(self, schedule_item: ScheduleItem) -> ScheduleItem: @@ -62,10 +62,10 @@ def update(self, schedule_item: ScheduleItem) -> ScheduleItem: error = "Schedule item missing ID." raise MissingRequiredFieldError(error) - url = f"{self.baseurl}/{schedule_item.id}" + url = "{0}/{1}".format(self.baseurl, schedule_item.id) update_req = RequestFactory.Schedule.update_req(schedule_item) server_response = self.put_request(url, update_req) - logger.info(f"Updated schedule item (ID: {schedule_item.id})") + logger.info("Updated schedule item (ID: {})".format(schedule_item.id)) updated_schedule = copy.copy(schedule_item) return updated_schedule._parse_common_tags(server_response.content, self.parent_srv.namespace) @@ -79,7 +79,7 @@ def create(self, schedule_item: ScheduleItem) -> ScheduleItem: create_req = RequestFactory.Schedule.create_req(schedule_item) server_response = self.post_request(url, create_req) new_schedule = ScheduleItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Created new schedule (ID: {new_schedule.id})") + logger.info("Created new schedule (ID: {})".format(new_schedule.id)) return new_schedule @api(version="2.8") @@ -91,12 +91,12 @@ def add_to_schedule( datasource: Optional["DatasourceItem"] = None, flow: Optional["FlowItem"] = None, task_type: Optional[str] = None, - ) -> list[AddResponse]: + ) -> List[AddResponse]: # There doesn't seem to be a good reason to allow one item of each type? if workbook and datasource: warnings.warn("Passing in multiple items for add_to_schedule will be deprecated", PendingDeprecationWarning) - items: list[ - tuple[str, Union[WorkbookItem, FlowItem, DatasourceItem], str, Callable[[Optional[str], str], bytes], str] + items: List[ + Tuple[str, Union[WorkbookItem, FlowItem, DatasourceItem], str, Callable[[Optional[str], str], bytes], str] ] = [] if workbook is not None: @@ -115,7 +115,8 @@ def add_to_schedule( ) # type:ignore[arg-type] results = (self._add_to(*x) for x in items) - return [x for x in results if not x.result] + # list() is needed for python 3.x compatibility + return list(filter(lambda x: not x.result, results)) # type:ignore[arg-type] def _add_to( self, @@ -132,13 +133,13 @@ def _add_to( item_task_type, ) -> AddResponse: id_ = resource.id - url = f"{self.siteurl}/{schedule_id}/{type_}s" + url = "{0}/{1}/{2}s".format(self.siteurl, schedule_id, type_) add_req = req_factory(id_, task_type=item_task_type) # type: ignore[call-arg, arg-type] response = self.put_request(url, add_req) error, warnings, task_created = ScheduleItem.parse_add_to_schedule_response(response, self.parent_srv.namespace) if task_created: - logger.info(f"Added {type_} to {id_} to schedule {schedule_id}") + logger.info("Added {} to {} to schedule {}".format(type_, id_, schedule_id)) if error is not None or warnings is not None: return AddResponse( diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index dc934496a..26aaf2910 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -1,5 +1,4 @@ import logging -from typing import Union from .endpoint import Endpoint, api from .exceptions import ServerResponseError @@ -22,49 +21,15 @@ def serverInfo(self): return self._info def __repr__(self): - return f"" + return "".format(self.serverInfo) @property - def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/serverInfo" + def baseurl(self): + return "{0}/serverInfo".format(self.parent_srv.baseurl) @api(version="2.4") - def get(self) -> Union[ServerInfoItem, None]: - """ - Retrieve the build and version information for the server. - - This method makes an unauthenticated call, so no sign in or - authentication token is required. - - Returns - ------- - :class:`~tableauserverclient.models.ServerInfoItem` - - Raises - ------ - :class:`~tableauserverclient.exceptions.ServerInfoEndpointNotFoundError` - Raised when the server info endpoint is not found. - - :class:`~tableauserverclient.exceptions.EndpointUnavailableError` - Raised when the server info endpoint is not available. - - Examples - -------- - >>> import tableauserverclient as TSC - - >>> # create a instance of server - >>> server = TSC.Server('https://MY-SERVER') - - >>> # set the version number > 2.3 - >>> # the server_info.get() method works in 2.4 and later - >>> server.version = '2.5' - - >>> s_info = server.server_info.get() - >>> print("\nServer info:") - >>> print("\tProduct version: {0}".format(s_info.product_version)) - >>> print("\tREST API version: {0}".format(s_info.rest_api_version)) - >>> print("\tBuild number: {0}".format(s_info.build_number)) - """ + def get(self): + """Retrieve the server info for the server. This is an unauthenticated call""" try: server_response = self.get_unauthenticated_request(self.baseurl) except ServerResponseError as e: diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index 55d2a5ad0..dfec49ae1 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -8,49 +8,20 @@ from tableauserverclient.helpers.logging import logger -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, List, Optional, Tuple if TYPE_CHECKING: from ..request_options import RequestOptions class Sites(Endpoint): - """ - Using the site methods of the Tableau Server REST API you can: - - List sites on a server or get details of a specific site - Create, update, or delete a site - List views in a site - Encrypt, decrypt, or reencrypt extracts on a site - - """ - @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites" + return "{0}/sites".format(self.parent_srv.baseurl) # Gets all sites @api(version="2.0") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[SiteItem], PaginationItem]: - """ - Query all sites on the server. This method requires server admin - permissions. This endpoint is paginated, meaning that the server will - only return a subset of the data at a time. The response will contain - information about the total number of sites and the number of sites - returned in the current response. Use the PaginationItem object to - request more data. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_sites - - Parameters - ---------- - req_options : RequestOptions, optional - Filtering options for the request. - - Returns - ------- - tuple[list[SiteItem], PaginationItem] - """ + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[SiteItem], PaginationItem]: logger.info("Querying all sites on site") logger.info("Requires Server Admin permissions") url = self.baseurl @@ -62,33 +33,6 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Site # Gets 1 site by id @api(version="2.0") def get_by_id(self, site_id: str) -> SiteItem: - """ - Query a single site on the server. You can only retrieve the site that - you are currently authenticated for. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_site - - Parameters - ---------- - site_id : str - The site ID. - - Returns - ------- - SiteItem - - Raises - ------ - ValueError - If the site ID is not defined. - - ValueError - If the site ID does not match the site for which you are currently authenticated. - - Examples - -------- - >>> site = server.sites.get_by_id('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') - """ if not site_id: error = "Site ID undefined." raise ValueError(error) @@ -96,45 +40,20 @@ def get_by_id(self, site_id: str) -> SiteItem: error = "You can only retrieve the site for which you are currently authenticated." raise ValueError(error) - logger.info(f"Querying single site (ID: {site_id})") - url = f"{self.baseurl}/{site_id}" + logger.info("Querying single site (ID: {0})".format(site_id)) + url = "{0}/{1}".format(self.baseurl, site_id) server_response = self.get_request(url) return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] # Gets 1 site by name @api(version="2.0") def get_by_name(self, site_name: str) -> SiteItem: - """ - Query a single site on the server. You can only retrieve the site that - you are currently authenticated for. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_site - - Parameters - ---------- - site_name : str - The site name. - - Returns - ------- - SiteItem - - Raises - ------ - ValueError - If the site name is not defined. - - Examples - -------- - >>> site = server.sites.get_by_name('Tableau') - - """ if not site_name: error = "Site Name undefined." raise ValueError(error) print("Note: You can only work with the site for which you are currently authenticated") - logger.info(f"Querying single site (Name: {site_name})") - url = f"{self.baseurl}/{site_name}?key=name" + logger.info("Querying single site (Name: {0})".format(site_name)) + url = "{0}/{1}?key=name".format(self.baseurl, site_name) print(self.baseurl, url) server_response = self.get_request(url) return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -142,31 +61,6 @@ def get_by_name(self, site_name: str) -> SiteItem: # Gets 1 site by content url @api(version="2.0") def get_by_content_url(self, content_url: str) -> SiteItem: - """ - Query a single site on the server. You can only retrieve the site that - you are currently authenticated for. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_site - - Parameters - ---------- - content_url : str - The content URL. - - Returns - ------- - SiteItem - - Raises - ------ - ValueError - If the site name is not defined. - - Examples - -------- - >>> site = server.sites.get_by_name('Tableau') - - """ if content_url is None: error = "Content URL undefined." raise ValueError(error) @@ -174,51 +68,15 @@ def get_by_content_url(self, content_url: str) -> SiteItem: error = "You can only work with the site you are currently authenticated for" raise ValueError(error) - logger.info(f"Querying single site (Content URL: {content_url})") + logger.info("Querying single site (Content URL: {0})".format(content_url)) logger.debug("Querying other sites requires Server Admin permissions") - url = f"{self.baseurl}/{content_url}?key=contentUrl" + url = "{0}/{1}?key=contentUrl".format(self.baseurl, content_url) server_response = self.get_request(url) return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] # Update site @api(version="2.0") def update(self, site_item: SiteItem) -> SiteItem: - """ - Modifies the settings for site. - - The site item object must include the site ID and overrides all other settings. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#update_site - - Parameters - ---------- - site_item : SiteItem - The site item that you want to update. The settings specified in the - site item override the current site settings. - - Returns - ------- - SiteItem - The site item object that was updated. - - Raises - ------ - MissingRequiredFieldError - If the site item is missing an ID. - - ValueError - If the site ID does not match the site for which you are currently authenticated. - - ValueError - If the site admin mode is set to ContentOnly and a user quota is also set. - - Examples - -------- - >>> ... - >>> site_item.name = 'New Name' - >>> updated_site = server.sites.update(site_item) - - """ if not site_item.id: error = "Site item missing ID." raise MissingRequiredFieldError(error) @@ -232,94 +90,30 @@ def update(self, site_item: SiteItem) -> SiteItem: error = "You cannot set admin_mode to ContentOnly and also set a user quota" raise ValueError(error) - url = f"{self.baseurl}/{site_item.id}" + url = "{0}/{1}".format(self.baseurl, site_item.id) update_req = RequestFactory.Site.update_req(site_item, self.parent_srv) server_response = self.put_request(url, update_req) - logger.info(f"Updated site item (ID: {site_item.id})") + logger.info("Updated site item (ID: {0})".format(site_item.id)) update_site = copy.copy(site_item) return update_site._parse_common_tags(server_response.content, self.parent_srv.namespace) # Delete 1 site object @api(version="2.0") def delete(self, site_id: str) -> None: - """ - Deletes the specified site from the server. You can only delete the site - if you are a Server Admin. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#delete_site - - Parameters - ---------- - site_id : str - The site ID. - - Raises - ------ - ValueError - If the site ID is not defined. - - ValueError - If the site ID does not match the site for which you are currently authenticated. - - Examples - -------- - >>> server.sites.delete('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') - """ if not site_id: error = "Site ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{site_id}" + url = "{0}/{1}".format(self.baseurl, site_id) if not site_id == self.parent_srv.site_id: error = "You can only delete the site you are currently authenticated for" raise ValueError(error) self.delete_request(url) self.parent_srv._clear_auth() - logger.info(f"Deleted single site (ID: {site_id}) and signed out") + logger.info("Deleted single site (ID: {0}) and signed out".format(site_id)) # Create new site @api(version="2.0") def create(self, site_item: SiteItem) -> SiteItem: - """ - Creates a new site on the server for the specified site item object. - - Tableau Server only. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#create_site - - Parameters - ---------- - site_item : SiteItem - The settings for the site that you want to create. You need to - create an instance of SiteItem and pass it to the create method. - - Returns - ------- - SiteItem - The site item object that was created. - - Raises - ------ - ValueError - If the site admin mode is set to ContentOnly and a user quota is also set. - - Examples - -------- - >>> import tableauserverclient as TSC - - >>> # create an instance of server - >>> server = TSC.Server('https://MY-SERVER') - - >>> # create shortcut for admin mode - >>> content_users=TSC.SiteItem.AdminMode.ContentAndUsers - - >>> # create a new SiteItem - >>> new_site = TSC.SiteItem(name='Tableau', content_url='tableau', admin_mode=content_users, user_quota=15, storage_quota=1000, disable_subscriptions=True) - - >>> # call the sites create method with the SiteItem - >>> new_site = server.sites.create(new_site) - - - """ if site_item.admin_mode: if site_item.admin_mode == SiteItem.AdminMode.ContentOnly and site_item.user_quota: error = "You cannot set admin_mode to ContentOnly and also set a user quota" @@ -329,92 +123,33 @@ def create(self, site_item: SiteItem) -> SiteItem: create_req = RequestFactory.Site.create_req(site_item, self.parent_srv) server_response = self.post_request(url, create_req) new_site = SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Created new site (ID: {new_site.id})") + logger.info("Created new site (ID: {0})".format(new_site.id)) return new_site @api(version="3.5") def encrypt_extracts(self, site_id: str) -> None: - """ - Encrypts all extracts on the site. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#encrypt_extracts - - Parameters - ---------- - site_id : str - The site ID. - - Raises - ------ - ValueError - If the site ID is not defined. - - Examples - -------- - >>> server.sites.encrypt_extracts('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') - """ if not site_id: error = "Site ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{site_id}/encrypt-extracts" + url = "{0}/{1}/encrypt-extracts".format(self.baseurl, site_id) empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) @api(version="3.5") def decrypt_extracts(self, site_id: str) -> None: - """ - Decrypts all extracts on the site. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#decrypt_extracts - - Parameters - ---------- - site_id : str - The site ID. - - Raises - ------ - ValueError - If the site ID is not defined. - - Examples - -------- - >>> server.sites.decrypt_extracts('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') - """ if not site_id: error = "Site ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{site_id}/decrypt-extracts" + url = "{0}/{1}/decrypt-extracts".format(self.baseurl, site_id) empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) @api(version="3.5") def re_encrypt_extracts(self, site_id: str) -> None: - """ - Reencrypt all extracts on a site with new encryption keys. If no site is - specified, extracts on the default site will be reencrypted. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#reencrypt_extracts - - Parameters - ---------- - site_id : str - The site ID. - - Raises - ------ - ValueError - If the site ID is not defined. - - Examples - -------- - >>> server.sites.re_encrypt_extracts('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') - - """ if not site_id: error = "Site ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{site_id}/reencrypt-extracts" + url = "{0}/{1}/reencrypt-extracts".format(self.baseurl, site_id) empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) diff --git a/tableauserverclient/server/endpoint/subscriptions_endpoint.py b/tableauserverclient/server/endpoint/subscriptions_endpoint.py index c9abc9b06..a9f2e7bf5 100644 --- a/tableauserverclient/server/endpoint/subscriptions_endpoint.py +++ b/tableauserverclient/server/endpoint/subscriptions_endpoint.py @@ -7,7 +7,7 @@ from tableauserverclient.helpers.logging import logger -from typing import Optional, TYPE_CHECKING +from typing import List, Optional, TYPE_CHECKING, Tuple if TYPE_CHECKING: from ..request_options import RequestOptions @@ -16,10 +16,10 @@ class Subscriptions(Endpoint): @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/subscriptions" + return "{0}/sites/{1}/subscriptions".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="2.3") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[SubscriptionItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[SubscriptionItem], PaginationItem]: logger.info("Querying all subscriptions for the site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -33,8 +33,8 @@ def get_by_id(self, subscription_id: str) -> SubscriptionItem: if not subscription_id: error = "No Subscription ID provided" raise ValueError(error) - logger.info(f"Querying a single subscription by id ({subscription_id})") - url = f"{self.baseurl}/{subscription_id}" + logger.info("Querying a single subscription by id ({})".format(subscription_id)) + url = "{}/{}".format(self.baseurl, subscription_id) server_response = self.get_request(url) return SubscriptionItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -43,7 +43,7 @@ def create(self, subscription_item: SubscriptionItem) -> SubscriptionItem: if not subscription_item: error = "No Susbcription provided" raise ValueError(error) - logger.info(f"Creating a subscription ({subscription_item})") + logger.info("Creating a subscription ({})".format(subscription_item)) url = self.baseurl create_req = RequestFactory.Subscription.create_req(subscription_item) server_response = self.post_request(url, create_req) @@ -54,17 +54,17 @@ def delete(self, subscription_id: str) -> None: if not subscription_id: error = "Subscription ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{subscription_id}" + url = "{0}/{1}".format(self.baseurl, subscription_id) self.delete_request(url) - logger.info(f"Deleted subscription (ID: {subscription_id})") + logger.info("Deleted subscription (ID: {0})".format(subscription_id)) @api(version="2.3") def update(self, subscription_item: SubscriptionItem) -> SubscriptionItem: if not subscription_item.id: error = "Subscription item missing ID. Subscription must be retrieved from server first." raise MissingRequiredFieldError(error) - url = f"{self.baseurl}/{subscription_item.id}" + url = "{0}/{1}".format(self.baseurl, subscription_item.id) update_req = RequestFactory.Subscription.update_req(subscription_item) server_response = self.put_request(url, update_req) - logger.info(f"Updated subscription item (ID: {subscription_item.id})") + logger.info("Updated subscription item (ID: {0})".format(subscription_item.id)) return SubscriptionItem.from_response(server_response.content, self.parent_srv.namespace)[0] diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index 120d3ba9c..36ef78c0a 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -1,6 +1,5 @@ import logging -from typing import Union -from collections.abc import Iterable +from typing import Iterable, Set, Union from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint from tableauserverclient.server.endpoint.endpoint import api, Endpoint @@ -16,14 +15,14 @@ class Tables(Endpoint, TaggingMixin[TableItem]): def __init__(self, parent_srv): - super().__init__(parent_srv) + super(Tables, self).__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "table") @property def baseurl(self): - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tables" + return "{0}/sites/{1}/tables".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="3.5") def get(self, req_options=None): @@ -40,8 +39,8 @@ def get_by_id(self, table_id): if not table_id: error = "table ID undefined." raise ValueError(error) - logger.info(f"Querying single table (ID: {table_id})") - url = f"{self.baseurl}/{table_id}" + logger.info("Querying single table (ID: {0})".format(table_id)) + url = "{0}/{1}".format(self.baseurl, table_id) server_response = self.get_request(url) return TableItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -50,9 +49,9 @@ def delete(self, table_id): if not table_id: error = "Database ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{table_id}" + url = "{0}/{1}".format(self.baseurl, table_id) self.delete_request(url) - logger.info(f"Deleted single table (ID: {table_id})") + logger.info("Deleted single table (ID: {0})".format(table_id)) @api(version="3.5") def update(self, table_item): @@ -60,10 +59,10 @@ def update(self, table_item): error = "table item missing ID." raise MissingRequiredFieldError(error) - url = f"{self.baseurl}/{table_item.id}" + url = "{0}/{1}".format(self.baseurl, table_item.id) update_req = RequestFactory.Table.update_req(table_item) server_response = self.put_request(url, update_req) - logger.info(f"Updated table item (ID: {table_item.id})") + logger.info("Updated table item (ID: {0})".format(table_item.id)) updated_table = TableItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_table @@ -81,10 +80,10 @@ def column_fetcher(): ) table_item._set_columns(column_fetcher) - logger.info(f"Populated columns for table (ID: {table_item.id}") + logger.info("Populated columns for table (ID: {0}".format(table_item.id)) def _get_columns_for_table(self, table_item, req_options=None): - url = f"{self.baseurl}/{table_item.id}/columns" + url = "{0}/{1}/columns".format(self.baseurl, table_item.id) server_response = self.get_request(url, req_options) columns = ColumnItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) @@ -92,12 +91,12 @@ def _get_columns_for_table(self, table_item, req_options=None): @api(version="3.5") def update_column(self, table_item, column_item): - url = f"{self.baseurl}/{table_item.id}/columns/{column_item.id}" + url = "{0}/{1}/columns/{2}".format(self.baseurl, table_item.id, column_item.id) update_req = RequestFactory.Column.update_req(column_item) server_response = self.put_request(url, update_req) column = ColumnItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Updated table item (ID: {table_item.id} & column item {column_item.id}") + logger.info("Updated table item (ID: {0} & column item {1}".format(table_item.id, column_item.id)) return column @api(version="3.5") @@ -129,7 +128,7 @@ def delete_dqw(self, item): self._data_quality_warnings.clear(item) @api(version="3.9") - def add_tags(self, item: Union[TableItem, str], tags: Union[Iterable[str], str]) -> set[str]: + def add_tags(self, item: Union[TableItem, str], tags: Union[Iterable[str], str]) -> Set[str]: return super().add_tags(item, tags) @api(version="3.9") diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index eb82c43bc..a727a515f 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -1,5 +1,5 @@ import logging -from typing import Optional, TYPE_CHECKING +from typing import List, Optional, Tuple, TYPE_CHECKING from tableauserverclient.server.endpoint.endpoint import Endpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError @@ -15,7 +15,7 @@ class Tasks(Endpoint): @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks" + return "{0}/sites/{1}/tasks".format(self.parent_srv.baseurl, self.parent_srv.site_id) def __normalize_task_type(self, task_type: str) -> str: """ @@ -23,20 +23,20 @@ def __normalize_task_type(self, task_type: str) -> str: It is different than the tag "extractRefresh" used in the request body. """ if task_type == TaskItem.Type.ExtractRefresh: - return f"{task_type}es" + return "{}es".format(task_type) else: return task_type @api(version="2.6") def get( self, req_options: Optional["RequestOptions"] = None, task_type: str = TaskItem.Type.ExtractRefresh - ) -> tuple[list[TaskItem], PaginationItem]: + ) -> Tuple[List[TaskItem], PaginationItem]: if task_type == TaskItem.Type.DataAcceleration: self.parent_srv.assert_at_least_version("3.8", "Data Acceleration Tasks") logger.info("Querying all %s tasks for the site", task_type) - url = f"{self.baseurl}/{self.__normalize_task_type(task_type)}" + url = "{0}/{1}".format(self.baseurl, self.__normalize_task_type(task_type)) server_response = self.get_request(url, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) @@ -63,7 +63,7 @@ def create(self, extract_item: TaskItem) -> TaskItem: error = "No extract refresh provided" raise ValueError(error) logger.info("Creating an extract refresh %s", extract_item) - url = f"{self.baseurl}/{self.__normalize_task_type(TaskItem.Type.ExtractRefresh)}" + 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 @@ -74,7 +74,7 @@ def run(self, task_item: TaskItem) -> bytes: error = "Task item missing ID." raise MissingRequiredFieldError(error) - url = "{}/{}/{}/runNow".format( + url = "{0}/{1}/{2}/runNow".format( self.baseurl, self.__normalize_task_type(TaskItem.Type.ExtractRefresh), task_item.id, @@ -92,6 +92,6 @@ def delete(self, task_id: str, task_type: str = TaskItem.Type.ExtractRefresh) -> if not task_id: error = "No Task ID provided" raise ValueError(error) - url = f"{self.baseurl}/{self.__normalize_task_type(task_type)}/{task_id}" + url = "{0}/{1}/{2}".format(self.baseurl, self.__normalize_task_type(task_type), task_id) self.delete_request(url) logger.info("Deleted single task (ID: %s)", task_id) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index d81907ae9..c4b6418b7 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -1,6 +1,6 @@ import copy import logging -from typing import Optional +from typing import List, Optional, Tuple from tableauserverclient.server.query import QuerySet @@ -14,75 +14,13 @@ class Users(QuerysetEndpoint[UserItem]): - """ - The user resources for Tableau Server are defined in the UserItem class. - The class corresponds to the user resources you can access using the - Tableau Server REST API. The user methods are based upon the endpoints for - users in the REST API and operate on the UserItem class. Only server and - site administrators can access the user resources. - """ - @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/users" + return "{0}/sites/{1}/users".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Gets all users @api(version="2.0") - def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[UserItem], PaginationItem]: - """ - Query all users on the site. Request is paginated and returns a subset of users. - By default, the request returns the first 100 users on the site. - - Parameters - ---------- - req_options : Optional[RequestOptions] - Optional request options to filter and sort the results. - - Returns - ------- - tuple[list[UserItem], PaginationItem] - Returns a tuple with a list of UserItem objects and a PaginationItem object. - - Raises - ------ - ServerResponseError - code: 400006 - summary: Invalid page number - detail: The page number is not an integer, is less than one, or is - greater than the final page number for users at the requested - page size. - - ServerResponseError - code: 400007 - summary: Invalid page size - detail: The page size parameter is not an integer, is less than one. - - ServerResponseError - code: 403014 - summary: Page size limit exceeded - detail: The specified page size is larger than the maximum page size - - ServerResponseError - code: 404000 - summary: Site not found - detail: The site ID in the URI doesn't correspond to an existing site. - - ServerResponseError - code: 405000 - summary: Invalid request method - detail: Request type was not GET. - - Examples - -------- - >>> import tableauserverclient as TSC - >>> tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') - >>> server = TSC.Server('https://SERVERURL') - - >>> with server.auth.sign_in(tableau_auth): - >>> users_page, pagination_item = server.users.get() - >>> print("\nThere are {} user on site: ".format(pagination_item.total_available)) - >>> print([user.name for user in users_page]) - """ + def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[UserItem], PaginationItem]: logger.info("Querying all users on site") if req_options is None: @@ -98,253 +36,55 @@ def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[UserIt # Gets 1 user by id @api(version="2.0") def get_by_id(self, user_id: str) -> UserItem: - """ - Query a single user by ID. - - Parameters - ---------- - user_id : str - The ID of the user to query. - - Returns - ------- - UserItem - The user item that was queried. - - Raises - ------ - ValueError - If the user ID is not specified. - - ServerResponseError - code: 404000 - summary: Site not found - detail: The site ID in the URI doesn't correspond to an existing site. - - ServerResponseError - code: 403133 - summary: Query user permissions forbidden - detail: The user does not have permissions to query user information - for other users - - ServerResponseError - code: 404002 - summary: User not found - detail: The user ID in the URI doesn't correspond to an existing user. - - ServerResponseError - code: 405000 - summary: Invalid request method - detail: Request type was not GET. - - Examples - -------- - >>> user1 = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') - """ if not user_id: error = "User ID undefined." raise ValueError(error) - logger.info(f"Querying single user (ID: {user_id})") - url = f"{self.baseurl}/{user_id}" + logger.info("Querying single user (ID: {0})".format(user_id)) + url = "{0}/{1}".format(self.baseurl, user_id) server_response = self.get_request(url) return UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() # Update user @api(version="2.0") def update(self, user_item: UserItem, password: Optional[str] = None) -> UserItem: - """ - Modifies information about the specified user. - - If Tableau Server is configured to use local authentication, you can - update the user's name, email address, password, or site role. - - If Tableau Server is configured to use Active Directory - authentication, you can change the user's display name (full name), - email address, and site role. However, if you synchronize the user with - Active Directory, the display name and email address will be - overwritten with the information that's in Active Directory. - - For Tableau Cloud, you can update the site role for a user, but you - cannot update or change a user's password, user name (email address), - or full name. - - Parameters - ---------- - user_item : UserItem - The user item to update. - - password : Optional[str] - The new password for the user. - - Returns - ------- - UserItem - The user item that was updated. - - Raises - ------ - MissingRequiredFieldError - If the user item is missing an ID. - - Examples - -------- - >>> user = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') - >>> user.fullname = 'New Full Name' - >>> updated_user = server.users.update(user) - - """ if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) - url = f"{self.baseurl}/{user_item.id}" + url = "{0}/{1}".format(self.baseurl, user_item.id) update_req = RequestFactory.User.update_req(user_item, password) server_response = self.put_request(url, update_req) - logger.info(f"Updated user item (ID: {user_item.id})") + logger.info("Updated user item (ID: {0})".format(user_item.id)) updated_item = copy.copy(user_item) return updated_item._parse_common_tags(server_response.content, self.parent_srv.namespace) # Delete 1 user by id @api(version="2.0") def remove(self, user_id: str, map_assets_to: Optional[str] = None) -> None: - """ - Removes a user from the site. You can also specify a user to map the - assets to when you remove the user. - - Parameters - ---------- - user_id : str - The ID of the user to remove. - - map_assets_to : Optional[str] - The ID of the user to map the assets to when you remove the user. - - Returns - ------- - None - - Raises - ------ - ValueError - If the user ID is not specified. - - Examples - -------- - >>> server.users.remove('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') - """ if not user_id: error = "User ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{user_id}" + url = "{0}/{1}".format(self.baseurl, user_id) if map_assets_to is not None: url += f"?mapAssetsTo={map_assets_to}" self.delete_request(url) - logger.info(f"Removed single user (ID: {user_id})") + logger.info("Removed single user (ID: {0})".format(user_id)) # Add new user to site @api(version="2.0") def add(self, user_item: UserItem) -> UserItem: - """ - Adds the user to the site. - - To add a new user to the site you need to first create a new user_item - (from UserItem class). When you create a new user, you specify the name - of the user and their site role. For Tableau Cloud, you also specify - the auth_setting attribute in your request. When you add user to - Tableau Cloud, the name of the user must be the email address that is - used to sign in to Tableau Cloud. After you add a user, Tableau Cloud - sends the user an email invitation. The user can click the link in the - invitation to sign in and update their full name and password. - - Parameters - ---------- - user_item : UserItem - The user item to add to the site. - - Returns - ------- - UserItem - The user item that was added to the site with attributes from the - site populated. - - Raises - ------ - ValueError - If the user item is missing a name - - ValueError - If the user item is missing a site role - - ServerResponseError - code: 400000 - summary: Bad Request - detail: The content of the request body is missing or incomplete, or - contains malformed XML. - - ServerResponseError - code: 400003 - summary: Bad Request - detail: The user authentication setting ServerDefault is not - supported for you site. Try again using TableauIDWithMFA instead. - - ServerResponseError - code: 400013 - summary: Invalid site role - detail: The value of the siteRole attribute must be Explorer, - ExplorerCanPublish, SiteAdministratorCreator, - SiteAdministratorExplorer, Unlicensed, or Viewer. - - ServerResponseError - code: 404000 - summary: Site not found - detail: The site ID in the URI doesn't correspond to an existing site. - - ServerResponseError - code: 404002 - summary: User not found - detail: The server is configured to use Active Directory for - authentication, and the username specified in the request body - doesn't match an existing user in Active Directory. - - ServerResponseError - code: 405000 - summary: Invalid request method - detail: Request type was not POST. - - ServerResponseError - code: 409000 - summary: User conflict - detail: The specified user already exists on the site. - - ServerResponseError - code: 409005 - summary: Guest user conflict - detail: The Tableau Server API doesn't allow adding a user with the - guest role to a site. - - - Examples - -------- - >>> import tableauserverclient as TSC - >>> server = TSC.Server('https://SERVERURL') - >>> # Login to the server - - >>> new_user = TSC.UserItem(name='new_user', site_role=TSC.UserItem.Role.Unlicensed) - >>> new_user = server.users.add(new_user) - - """ url = self.baseurl - logger.info(f"Add user {user_item.name}") + logger.info("Add user {}".format(user_item.name)) add_req = RequestFactory.User.add_req(user_item) server_response = self.post_request(url, add_req) logger.info(server_response) new_user = UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() - logger.info(f"Added new user (ID: {new_user.id})") + logger.info("Added new user (ID: {0})".format(new_user.id)) return new_user # Add new users to site. This does not actually perform a bulk action, it's syntactic sugar @api(version="2.0") - def add_all(self, users: list[UserItem]): + def add_all(self, users: List[UserItem]): created = [] failed = [] for user in users: @@ -358,7 +98,7 @@ def add_all(self, users: list[UserItem]): # helping the user by parsing a file they could have used to add users through the UI # line format: Username [required], password, display name, license, admin, publish @api(version="2.0") - def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[UserItem, ServerResponseError]]]: + def create_from_file(self, filepath: str) -> Tuple[List[UserItem], List[Tuple[UserItem, ServerResponseError]]]: created = [] failed = [] if not filepath.find("csv"): @@ -382,42 +122,6 @@ def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[Us # Get workbooks for user @api(version="2.0") def populate_workbooks(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: - """ - Returns information about the workbooks that the specified user owns - and has Read (view) permissions for. - - This method retrieves the workbook information for the specified user. - The REST API is designed to return only the information you ask for - explicitly. When you query for all the users, the workbook information - for each user is not included. Use this method to retrieve information - about the workbooks that the user owns or has Read (view) permissions. - The method adds the list of workbooks to the user item object - (user_item.workbooks). - - Parameters - ---------- - user_item : UserItem - The user item to populate workbooks for. - - req_options : Optional[RequestOptions] - Optional request options to filter and sort the results. - - Returns - ------- - None - - Raises - ------ - MissingRequiredFieldError - If the user item is missing an ID. - - Examples - -------- - >>> user = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') - >>> server.users.populate_workbooks(user) - >>> for wb in user.workbooks: - >>> print(wb.name) - """ if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) @@ -429,71 +133,20 @@ def wb_pager(): def _get_wbs_for_user( self, user_item: UserItem, req_options: Optional[RequestOptions] = None - ) -> tuple[list[WorkbookItem], PaginationItem]: - url = f"{self.baseurl}/{user_item.id}/workbooks" + ) -> Tuple[List[WorkbookItem], PaginationItem]: + url = "{0}/{1}/workbooks".format(self.baseurl, user_item.id) server_response = self.get_request(url, req_options) - logger.info(f"Populated workbooks for user (ID: {user_item.id})") + logger.info("Populated workbooks for user (ID: {0})".format(user_item.id)) workbook_item = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) return workbook_item, pagination_item def populate_favorites(self, user_item: UserItem) -> None: - """ - Populate the favorites for the user. - - Parameters - ---------- - user_item : UserItem - The user item to populate favorites for. - - Returns - ------- - None - - Examples - -------- - >>> import tableauserverclient as TSC - >>> server = TSC.Server('https://SERVERURL') - >>> # Login to the server - - >>> user = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') - >>> server.users.populate_favorites(user) - >>> for obj_type, items in user.favorites.items(): - >>> print(f"Favorites for {obj_type}:") - >>> for item in items: - >>> print(item.name) - """ self.parent_srv.favorites.get(user_item) # Get groups for user @api(version="3.7") def populate_groups(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: - """ - Populate the groups for the user. - - Parameters - ---------- - user_item : UserItem - The user item to populate groups for. - - req_options : Optional[RequestOptions] - Optional request options to filter and sort the results. - - Returns - ------- - None - - Raises - ------ - MissingRequiredFieldError - If the user item is missing an ID. - - Examples - -------- - >>> server.users.populate_groups(user) - >>> for group in user.groups: - >>> print(group.name) - """ if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) @@ -508,10 +161,10 @@ def groups_for_user_pager(): def _get_groups_for_user( self, user_item: UserItem, req_options: Optional[RequestOptions] = None - ) -> tuple[list[GroupItem], PaginationItem]: - url = f"{self.baseurl}/{user_item.id}/groups" + ) -> Tuple[List[GroupItem], PaginationItem]: + url = "{0}/{1}/groups".format(self.baseurl, user_item.id) server_response = self.get_request(url, req_options) - logger.info(f"Populated groups for user (ID: {user_item.id})") + logger.info("Populated groups for user (ID: {0})".format(user_item.id)) group_item = GroupItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) return group_item, pagination_item diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 3709fc41d..f2ccf658e 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -11,8 +11,7 @@ from tableauserverclient.helpers.logging import logger -from typing import Optional, TYPE_CHECKING, Union -from collections.abc import Iterable, Iterator +from typing import Iterable, Iterator, List, Optional, Set, Tuple, TYPE_CHECKING, Union if TYPE_CHECKING: from tableauserverclient.server.request_options import ( @@ -26,22 +25,22 @@ class Views(QuerysetEndpoint[ViewItem], TaggingMixin[ViewItem]): def __init__(self, parent_srv): - super().__init__(parent_srv) + super(Views, self).__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) # Used because populate_preview_image functionaliy requires workbook endpoint @property def siteurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}" + return "{0}/sites/{1}".format(self.parent_srv.baseurl, self.parent_srv.site_id) @property def baseurl(self) -> str: - return f"{self.siteurl}/views" + return "{0}/views".format(self.siteurl) @api(version="2.2") def get( self, req_options: Optional["RequestOptions"] = None, usage: bool = False - ) -> tuple[list[ViewItem], PaginationItem]: + ) -> Tuple[List[ViewItem], PaginationItem]: logger.info("Querying all views on site") url = self.baseurl if usage: @@ -56,8 +55,8 @@ 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(f"Querying single view (ID: {view_id})") - url = f"{self.baseurl}/{view_id}" + 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) @@ -73,10 +72,10 @@ def image_fetcher(): return self._get_preview_for_view(view_item) view_item._set_preview_image(image_fetcher) - logger.info(f"Populated preview image for view (ID: {view_item.id})") + logger.info("Populated preview image for view (ID: {0})".format(view_item.id)) def _get_preview_for_view(self, view_item: ViewItem) -> bytes: - url = f"{self.siteurl}/workbooks/{view_item.workbook_id}/views/{view_item.id}/previewImage" + url = "{0}/workbooks/{1}/views/{2}/previewImage".format(self.siteurl, view_item.workbook_id, view_item.id) server_response = self.get_request(url) image = server_response.content return image @@ -91,10 +90,10 @@ def image_fetcher(): return self._get_view_image(view_item, req_options) view_item._set_image(image_fetcher) - logger.info(f"Populated image for view (ID: {view_item.id})") + logger.info("Populated image for view (ID: {0})".format(view_item.id)) def _get_view_image(self, view_item: ViewItem, req_options: Optional["ImageRequestOptions"]) -> bytes: - url = f"{self.baseurl}/{view_item.id}/image" + url = "{0}/{1}/image".format(self.baseurl, view_item.id) server_response = self.get_request(url, req_options) image = server_response.content return image @@ -109,10 +108,10 @@ def pdf_fetcher(): return self._get_view_pdf(view_item, req_options) view_item._set_pdf(pdf_fetcher) - logger.info(f"Populated pdf for view (ID: {view_item.id})") + logger.info("Populated pdf for view (ID: {0})".format(view_item.id)) def _get_view_pdf(self, view_item: ViewItem, req_options: Optional["PDFRequestOptions"]) -> bytes: - url = f"{self.baseurl}/{view_item.id}/pdf" + url = "{0}/{1}/pdf".format(self.baseurl, view_item.id) server_response = self.get_request(url, req_options) pdf = server_response.content return pdf @@ -127,10 +126,10 @@ def csv_fetcher(): return self._get_view_csv(view_item, req_options) view_item._set_csv(csv_fetcher) - logger.info(f"Populated csv for view (ID: {view_item.id})") + logger.info("Populated csv for view (ID: {0})".format(view_item.id)) def _get_view_csv(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"]) -> Iterator[bytes]: - url = f"{self.baseurl}/{view_item.id}/data" + url = "{0}/{1}/data".format(self.baseurl, view_item.id) with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: yield from server_response.iter_content(1024) @@ -145,10 +144,10 @@ def excel_fetcher(): return self._get_view_excel(view_item, req_options) view_item._set_excel(excel_fetcher) - logger.info(f"Populated excel for view (ID: {view_item.id})") + logger.info("Populated excel for view (ID: {0})".format(view_item.id)) def _get_view_excel(self, view_item: ViewItem, req_options: Optional["ExcelRequestOptions"]) -> Iterator[bytes]: - url = f"{self.baseurl}/{view_item.id}/crosstab/excel" + url = "{0}/{1}/crosstab/excel".format(self.baseurl, view_item.id) with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: yield from server_response.iter_content(1024) @@ -177,7 +176,7 @@ def update(self, view_item: ViewItem) -> ViewItem: return view_item @api(version="1.0") - def add_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) -> set[str]: + def add_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) -> Set[str]: return super().add_tags(item, tags) @api(version="1.0") diff --git a/tableauserverclient/server/endpoint/virtual_connections_endpoint.py b/tableauserverclient/server/endpoint/virtual_connections_endpoint.py index 944b72502..f71db00cc 100644 --- a/tableauserverclient/server/endpoint/virtual_connections_endpoint.py +++ b/tableauserverclient/server/endpoint/virtual_connections_endpoint.py @@ -1,8 +1,7 @@ from functools import partial import json from pathlib import Path -from typing import Optional, TYPE_CHECKING, Union -from collections.abc import Iterable +from typing import Iterable, List, Optional, Set, TYPE_CHECKING, Tuple, Union from tableauserverclient.models.connection_item import ConnectionItem from tableauserverclient.models.pagination_item import PaginationItem @@ -29,7 +28,7 @@ def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/virtualConnections" @api(version="3.18") - def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[VirtualConnectionItem], PaginationItem]: + def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[VirtualConnectionItem], PaginationItem]: server_response = self.get_request(self.baseurl, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) virtual_connections = VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace) @@ -45,7 +44,7 @@ def _connection_fetcher(): def _get_virtual_database_connections( self, virtual_connection: VirtualConnectionItem, req_options: Optional[RequestOptions] = None - ) -> tuple[list[ConnectionItem], PaginationItem]: + ) -> Tuple[List[ConnectionItem], PaginationItem]: server_response = self.get_request(f"{self.baseurl}/{virtual_connection.id}/connections", req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) @@ -84,7 +83,7 @@ def update(self, virtual_connection: VirtualConnectionItem) -> VirtualConnection @api(version="3.23") def get_revisions( self, virtual_connection: VirtualConnectionItem, req_options: Optional[RequestOptions] = None - ) -> tuple[list[RevisionItem], PaginationItem]: + ) -> Tuple[List[RevisionItem], PaginationItem]: server_response = self.get_request(f"{self.baseurl}/{virtual_connection.id}/revisions", req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, virtual_connection) @@ -160,7 +159,7 @@ def delete_permission(self, item, capability_item): @api(version="3.23") def add_tags( self, virtual_connection: Union[VirtualConnectionItem, str], tags: Union[Iterable[str], str] - ) -> set[str]: + ) -> Set[str]: return super().add_tags(virtual_connection, tags) @api(version="3.23") diff --git a/tableauserverclient/server/endpoint/webhooks_endpoint.py b/tableauserverclient/server/endpoint/webhooks_endpoint.py index 06643f99d..597f9c425 100644 --- a/tableauserverclient/server/endpoint/webhooks_endpoint.py +++ b/tableauserverclient/server/endpoint/webhooks_endpoint.py @@ -6,7 +6,7 @@ from tableauserverclient.helpers.logging import logger -from typing import Optional, TYPE_CHECKING +from typing import List, Optional, TYPE_CHECKING, Tuple if TYPE_CHECKING: from ..server import Server @@ -15,14 +15,14 @@ class Webhooks(Endpoint): def __init__(self, parent_srv: "Server") -> None: - super().__init__(parent_srv) + super(Webhooks, self).__init__(parent_srv) @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/webhooks" + return "{0}/sites/{1}/webhooks".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="3.6") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[WebhookItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[WebhookItem], PaginationItem]: logger.info("Querying all Webhooks on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -35,8 +35,8 @@ def get_by_id(self, webhook_id: str) -> WebhookItem: if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) - logger.info(f"Querying single webhook (ID: {webhook_id})") - url = f"{self.baseurl}/{webhook_id}" + logger.info("Querying single webhook (ID: {0})".format(webhook_id)) + url = "{0}/{1}".format(self.baseurl, webhook_id) server_response = self.get_request(url) return WebhookItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -45,9 +45,9 @@ def delete(self, webhook_id: str) -> None: if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{webhook_id}" + url = "{0}/{1}".format(self.baseurl, webhook_id) self.delete_request(url) - logger.info(f"Deleted single webhook (ID: {webhook_id})") + logger.info("Deleted single webhook (ID: {0})".format(webhook_id)) @api(version="3.6") def create(self, webhook_item: WebhookItem) -> WebhookItem: @@ -56,7 +56,7 @@ def create(self, webhook_item: WebhookItem) -> WebhookItem: server_response = self.post_request(url, create_req) new_webhook = WebhookItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Created new webhook (ID: {new_webhook.id})") + logger.info("Created new webhook (ID: {0})".format(new_webhook.id)) return new_webhook @api(version="3.6") @@ -64,7 +64,7 @@ def test(self, webhook_id: str): if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{webhook_id}/test" + url = "{0}/{1}/test".format(self.baseurl, webhook_id) testOutcome = self.get_request(url) - logger.info(f"Testing webhook (ID: {webhook_id} returned {testOutcome})") + logger.info("Testing webhook (ID: {0} returned {1})".format(webhook_id, testOutcome)) return testOutcome diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 460017d1a..da6eda3de 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -7,7 +7,6 @@ from pathlib import Path from tableauserverclient.helpers.headers import fix_filename -from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.server.query import QuerySet from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api, parameter_added_in @@ -26,11 +25,15 @@ from tableauserverclient.server import RequestFactory from typing import ( + Iterable, + List, Optional, + Sequence, + Set, + Tuple, TYPE_CHECKING, Union, ) -from collections.abc import Iterable, Sequence if TYPE_CHECKING: from tableauserverclient.server import Server @@ -58,34 +61,18 @@ class Workbooks(QuerysetEndpoint[WorkbookItem], TaggingMixin[WorkbookItem]): def __init__(self, parent_srv: "Server") -> None: - super().__init__(parent_srv) + super(Workbooks, self).__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) return None @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/workbooks" + return "{0}/sites/{1}/workbooks".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Get all workbooks on site @api(version="2.0") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[WorkbookItem], PaginationItem]: - """ - Queries the server and returns information about the workbooks the site. - - Parameters - ---------- - req_options : RequestOptions, optional - (Optional) You can pass the method a request object that contains - additional parameters to filter the request. For example, if you - were searching for a specific workbook, you could specify the name - of the workbook or the name of the owner. - - Returns - ------- - Tuple containing one page's worth of workbook items and pagination - information. - """ + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[WorkbookItem], PaginationItem]: logger.info("Querying all workbooks on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -96,44 +83,18 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Work # Get 1 workbook @api(version="2.0") def get_by_id(self, workbook_id: str) -> WorkbookItem: - """ - Returns information about the specified workbook on the site. - - Parameters - ---------- - workbook_id : str - The workbook ID. - - Returns - ------- - WorkbookItem - The workbook item. - """ if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) - logger.info(f"Querying single workbook (ID: {workbook_id})") - url = f"{self.baseurl}/{workbook_id}" + logger.info("Querying single workbook (ID: {0})".format(workbook_id)) + url = "{0}/{1}".format(self.baseurl, workbook_id) server_response = self.get_request(url) return WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="2.8") def refresh(self, workbook_item: Union[WorkbookItem, str]) -> JobItem: - """ - Refreshes the extract of an existing workbook. - - Parameters - ---------- - workbook_item : WorkbookItem | str - The workbook item or workbook ID. - - Returns - ------- - JobItem - The job item. - """ id_ = getattr(workbook_item, "id", workbook_item) - url = f"{self.baseurl}/{id_}/refresh" + url = "{0}/{1}/refresh".format(self.baseurl, id_) empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -146,37 +107,10 @@ def create_extract( workbook_item: WorkbookItem, encrypt: bool = False, includeAll: bool = True, - datasources: Optional[list["DatasourceItem"]] = None, + datasources: Optional[List["DatasourceItem"]] = None, ) -> JobItem: - """ - Create one or more extracts on 1 workbook, optionally encrypted. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#create_extracts_for_workbook - - Parameters - ---------- - workbook_item : WorkbookItem - The workbook item to create extracts for. - - encrypt : bool, default False - Set to True to encrypt the extracts. - - includeAll : bool, default True - If True, all data sources in the workbook will have an extract - created for them. If False, then a data source must be supplied in - the request. - - datasources : list[DatasourceItem] | None - List of DatasourceItem objects for the data sources to create - extracts for. Only required if includeAll is False. - - Returns - ------- - JobItem - The job item for the extract creation. - """ id_ = getattr(workbook_item, "id", workbook_item) - url = f"{self.baseurl}/{id_}/createExtract?encrypt={encrypt}" + url = "{0}/{1}/createExtract?encrypt={2}".format(self.baseurl, id_, encrypt) datasource_req = RequestFactory.Workbook.embedded_extract_req(includeAll, datasources) server_response = self.post_request(url, datasource_req) @@ -186,31 +120,8 @@ def create_extract( # delete all the extracts on 1 workbook @api(version="3.3") def delete_extract(self, workbook_item: WorkbookItem, includeAll: bool = True, datasources=None) -> JobItem: - """ - Delete all extracts of embedded datasources on 1 workbook. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#delete_extracts_from_workbook - - Parameters - ---------- - workbook_item : WorkbookItem - The workbook item to delete extracts from. - - includeAll : bool, default True - If True, all data sources in the workbook will have their extracts - deleted. If False, then a data source must be supplied in the - request. - - datasources : list[DatasourceItem] | None - List of DatasourceItem objects for the data sources to delete - extracts from. Only required if includeAll is False. - - Returns - ------- - JobItem - """ id_ = getattr(workbook_item, "id", workbook_item) - url = f"{self.baseurl}/{id_}/deleteExtract" + url = "{0}/{1}/deleteExtract".format(self.baseurl, id_) datasource_req = RequestFactory.Workbook.embedded_extract_req(includeAll, datasources) server_response = self.post_request(url, datasource_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -219,24 +130,12 @@ def delete_extract(self, workbook_item: WorkbookItem, includeAll: bool = True, d # Delete 1 workbook by id @api(version="2.0") def delete(self, workbook_id: str) -> None: - """ - Deletes a workbook with the specified ID. - - Parameters - ---------- - workbook_id : str - The workbook ID. - - Returns - ------- - None - """ if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{workbook_id}" + url = "{0}/{1}".format(self.baseurl, workbook_id) self.delete_request(url) - logger.info(f"Deleted single workbook (ID: {workbook_id})") + logger.info("Deleted single workbook (ID: {0})".format(workbook_id)) # Update workbook @api(version="2.0") @@ -246,29 +145,6 @@ def update( workbook_item: WorkbookItem, include_view_acceleration_status: bool = False, ) -> WorkbookItem: - """ - Modifies an existing workbook. Use this method to change the owner or - the project that the workbook belongs to, or to change whether the - workbook shows views in tabs. The workbook item must include the - workbook ID and overrides the existing settings. - - See https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#update_workbook - for a list of fields that can be updated. - - Parameters - ---------- - workbook_item : WorkbookItem - The workbook item to update. ID is required. Other fields are - optional. Any fields that are not specified will not be changed. - - include_view_acceleration_status : bool, default False - Set to True to include the view acceleration status in the response. - - Returns - ------- - WorkbookItem - The updated workbook item. - """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -276,47 +152,27 @@ def update( self.update_tags(workbook_item) # Update the workbook itself - url = f"{self.baseurl}/{workbook_item.id}" + 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(f"Updated workbook item (ID: {workbook_item.id})") + logger.info("Updated workbook item (ID: {0})".format(workbook_item.id)) updated_workbook = copy.copy(workbook_item) return updated_workbook._parse_common_tags(server_response.content, self.parent_srv.namespace) # Update workbook_connection @api(version="2.3") def update_connection(self, workbook_item: WorkbookItem, connection_item: ConnectionItem) -> ConnectionItem: - """ - Updates a workbook connection information (server addres, server port, - user name, and password). - - The workbook connections must be populated before the strings can be - updated. - - Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#update_workbook_connection - - Parameters - ---------- - workbook_item : WorkbookItem - The workbook item to update. - - connection_item : ConnectionItem - The connection item to update. - - Returns - ------- - ConnectionItem - The updated connection item. - """ - url = f"{self.baseurl}/{workbook_item.id}/connections/{connection_item.id}" + url = "{0}/{1}/connections/{2}".format(self.baseurl, workbook_item.id, connection_item.id) update_req = RequestFactory.Connection.update_req(connection_item) server_response = self.put_request(url, update_req) connection = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Updated workbook item (ID: {workbook_item.id} & connection item {connection_item.id})") + logger.info( + "Updated workbook item (ID: {0} & connection item {1})".format(workbook_item.id, connection_item.id) + ) return connection # Download workbook contents with option of passing in filepath @@ -329,34 +185,6 @@ def download( filepath: Optional[PathOrFileW] = None, include_extract: bool = True, ) -> PathOrFileW: - """ - Downloads a workbook to the specified directory (optional). - - Parameters - ---------- - workbook_id : str - The workbook ID. - - filepath : Path or File object, optional - Downloads the file to the location you specify. If no location is - specified, the file is downloaded to the current working directory. - The default is Filepath=None. - - include_extract : bool, default True - Set to False to exclude the extract from the download. The default - is True. - - Returns - ------- - Path or File object - The path to the downloaded workbook or the file object. - - Raises - ------ - ValueError - If the workbook ID is not defined. - """ - return self.download_revision( workbook_id, None, @@ -367,48 +195,18 @@ def download( # Get all views of workbook @api(version="2.0") def populate_views(self, workbook_item: WorkbookItem, usage: bool = False) -> None: - """ - Populates (or gets) a list of views for a workbook. - - You must first call this method to populate views before you can iterate - through the views. - - This method retrieves the view information for the specified workbook. - The REST API is designed to return only the information you ask for - explicitly. When you query for all the workbooks, the view information - is not included. Use this method to retrieve the views. The method adds - the list of views to the workbook item (workbook_item.views). This is a - list of ViewItem. - - Parameters - ---------- - workbook_item : WorkbookItem - The workbook item to populate views for. - - usage : bool, default False - Set to True to include usage statistics for each view. - - Returns - ------- - None - - Raises - ------ - MissingRequiredFieldError - If the workbook item is missing an ID. - """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) - def view_fetcher() -> list[ViewItem]: + def view_fetcher() -> List[ViewItem]: return self._get_views_for_workbook(workbook_item, usage) workbook_item._set_views(view_fetcher) - logger.info(f"Populated views for workbook (ID: {workbook_item.id})") + logger.info("Populated views for workbook (ID: {0})".format(workbook_item.id)) - def _get_views_for_workbook(self, workbook_item: WorkbookItem, usage: bool) -> list[ViewItem]: - url = f"{self.baseurl}/{workbook_item.id}/views" + def _get_views_for_workbook(self, workbook_item: WorkbookItem, usage: bool) -> List[ViewItem]: + url = "{0}/{1}/views".format(self.baseurl, workbook_item.id) if usage: url += "?includeUsageStatistics=true" server_response = self.get_request(url) @@ -422,36 +220,6 @@ def _get_views_for_workbook(self, workbook_item: WorkbookItem, usage: bool) -> l # Get all connections of workbook @api(version="2.0") def populate_connections(self, workbook_item: WorkbookItem) -> None: - """ - Populates a list of data source connections for the specified workbook. - - You must populate connections before you can iterate through the - connections. - - This method retrieves the data source connection information for the - specified workbook. The REST API is designed to return only the - information you ask for explicitly. When you query all the workbooks, - the data source connection information is not included. Use this method - to retrieve the connection information for any data sources used by the - workbook. The method adds the list of data connections to the workbook - item (workbook_item.connections). This is a list of ConnectionItem. - - REST API docs: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_workbook_connections - - Parameters - ---------- - workbook_item : WorkbookItem - The workbook item to populate connections for. - - Returns - ------- - None - - Raises - ------ - MissingRequiredFieldError - If the workbook item is missing an ID. - """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -460,12 +228,12 @@ def connection_fetcher(): return self._get_workbook_connections(workbook_item) workbook_item._set_connections(connection_fetcher) - logger.info(f"Populated connections for workbook (ID: {workbook_item.id})") + logger.info("Populated connections for workbook (ID: {0})".format(workbook_item.id)) def _get_workbook_connections( self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None - ) -> list[ConnectionItem]: - url = f"{self.baseurl}/{workbook_item.id}/connections" + ) -> List[ConnectionItem]: + url = "{0}/{1}/connections".format(self.baseurl, workbook_item.id) server_response = self.get_request(url, req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) return connections @@ -473,34 +241,6 @@ def _get_workbook_connections( # Get the pdf of the entire workbook if its tabs are enabled, pdf of the default view if its tabs are disabled @api(version="3.4") def populate_pdf(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None) -> None: - """ - Populates the PDF for the specified workbook item. - - This method populates a PDF with image(s) of the workbook view(s) you - specify. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#download_workbook_pdf - - Parameters - ---------- - workbook_item : WorkbookItem - The workbook item to populate the PDF for. - - req_options : RequestOptions, optional - (Optional) You can pass in request options to specify the page type - and orientation of the PDF content, as well as the maximum age of - the PDF rendered on the server. See PDFRequestOptions class for more - details. - - Returns - ------- - None - - Raises - ------ - MissingRequiredFieldError - If the workbook item is missing an ID. - """ if not workbook_item.id: error = "Workbook item missing ID." raise MissingRequiredFieldError(error) @@ -509,46 +249,16 @@ def pdf_fetcher() -> bytes: return self._get_wb_pdf(workbook_item, req_options) workbook_item._set_pdf(pdf_fetcher) - logger.info(f"Populated pdf for workbook (ID: {workbook_item.id})") + logger.info("Populated pdf for workbook (ID: {0})".format(workbook_item.id)) def _get_wb_pdf(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"]) -> bytes: - url = f"{self.baseurl}/{workbook_item.id}/pdf" + url = "{0}/{1}/pdf".format(self.baseurl, workbook_item.id) server_response = self.get_request(url, req_options) pdf = server_response.content return pdf @api(version="3.8") def populate_powerpoint(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None) -> None: - """ - Populates the PowerPoint for the specified workbook item. - - This method populates a PowerPoint with image(s) of the workbook view(s) you - specify. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#download_workbook_powerpoint - - Parameters - ---------- - workbook_item : WorkbookItem - The workbook item to populate the PDF for. - - req_options : RequestOptions, optional - (Optional) You can pass in request options to specify the maximum - number of minutes a workbook .pptx will be cached before being - refreshed. To prevent multiple .pptx requests from overloading the - server, the shortest interval you can set is one minute. There is no - maximum value, but the server job enacting the caching action may - expire before a long cache period is reached. - - Returns - ------- - None - - Raises - ------ - MissingRequiredFieldError - If the workbook item is missing an ID. - """ if not workbook_item.id: error = "Workbook item missing ID." raise MissingRequiredFieldError(error) @@ -557,10 +267,10 @@ def pptx_fetcher() -> bytes: return self._get_wb_pptx(workbook_item, req_options) workbook_item._set_powerpoint(pptx_fetcher) - logger.info(f"Populated powerpoint for workbook (ID: {workbook_item.id})") + logger.info("Populated powerpoint for workbook (ID: {0})".format(workbook_item.id)) def _get_wb_pptx(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"]) -> bytes: - url = f"{self.baseurl}/{workbook_item.id}/powerpoint" + url = "{0}/{1}/powerpoint".format(self.baseurl, workbook_item.id) server_response = self.get_request(url, req_options) pptx = server_response.content return pptx @@ -568,26 +278,6 @@ def _get_wb_pptx(self, workbook_item: WorkbookItem, req_options: Optional["Reque # Get preview image of workbook @api(version="2.0") def populate_preview_image(self, workbook_item: WorkbookItem) -> None: - """ - This method gets the preview image (thumbnail) for the specified workbook item. - - This method uses the workbook's ID to get the preview image. The method - adds the preview image to the workbook item (workbook_item.preview_image). - - Parameters - ---------- - workbook_item : WorkbookItem - The workbook item to populate the preview image for. - - Returns - ------- - None - - Raises - ------ - MissingRequiredFieldError - If the workbook item is missing an ID. - """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -596,75 +286,24 @@ def image_fetcher() -> bytes: return self._get_wb_preview_image(workbook_item) workbook_item._set_preview_image(image_fetcher) - logger.info(f"Populated preview image for workbook (ID: {workbook_item.id})") + logger.info("Populated preview image for workbook (ID: {0})".format(workbook_item.id)) def _get_wb_preview_image(self, workbook_item: WorkbookItem) -> bytes: - url = f"{self.baseurl}/{workbook_item.id}/previewImage" + url = "{0}/{1}/previewImage".format(self.baseurl, workbook_item.id) server_response = self.get_request(url) preview_image = server_response.content return preview_image @api(version="2.0") def populate_permissions(self, item: WorkbookItem) -> None: - """ - Populates the permissions for the specified workbook item. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_workbook_permissions - - Parameters - ---------- - item : WorkbookItem - The workbook item to populate permissions for. - - Returns - ------- - None - """ self._permissions.populate(item) @api(version="2.0") - def update_permissions(self, resource: WorkbookItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: - """ - Updates the permissions for the specified workbook item. The method - replaces the existing permissions with the new permissions. Any missing - permissions are removed. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_permissions_for_content - - Parameters - ---------- - resource : WorkbookItem - The workbook item to update permissions for. - - rules : list[PermissionsRule] - A list of permissions rules to apply to the workbook item. - - Returns - ------- - list[PermissionsRule] - The updated permissions rules. - """ + def update_permissions(self, resource, rules): return self._permissions.update(resource, rules) @api(version="2.0") - def delete_permission(self, item: WorkbookItem, capability_item: PermissionsRule) -> None: - """ - Deletes a single permission rule from the specified workbook item. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_workbook_permission - - Parameters - ---------- - item : WorkbookItem - The workbook item to delete the permission from. - - capability_item : PermissionsRule - The permission rule to delete. - - Returns - ------- - None - """ + def delete_permission(self, item, capability_item): return self._permissions.delete(item, capability_item) @api(version="2.0") @@ -680,87 +319,10 @@ def publish( skip_connection_check: bool = False, parameters=None, ): - """ - Publish a workbook to the specified site. - - Note: The REST API cannot automatically include extracts or other - resources that the workbook uses. Therefore, a .twb file that uses data - from an Excel or csv file on a local computer cannot be published, - unless you package the data and workbook in a .twbx file, or publish the - data source separately. - - For workbooks that are larger than 64 MB, the publish method - automatically takes care of chunking the file in parts for uploading. - Using this method is considerably more convenient than calling the - publish REST APIs directly. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#publish_workbook - - Parameters - ---------- - workbook_item : WorkbookItem - The workbook_item specifies the workbook you are publishing. When - you are adding a workbook, you need to first create a new instance - of a workbook_item that includes a project_id of an existing - project. The name of the workbook will be the name of the file, - unless you also specify a name for the new workbook when you create - the instance. - - file : Path or File object - The file path or file object of the workbook to publish. When - providing a file object, you must also specifiy the name of the - workbook in your instance of the workbook_itemworkbook_item , as - the name cannot be derived from the file name. - - mode : str - Specifies whether you are publishing a new workbook (CreateNew) or - overwriting an existing workbook (Overwrite). You cannot appending - workbooks. You can also use the publish mode attributes, for - example: TSC.Server.PublishMode.Overwrite. - - connections : list[ConnectionItem] | None - List of ConnectionItems objects for the connections created within - the workbook. - - as_job : bool, default False - Set to True to run the upload as a job (asynchronous upload). If set - to True a job will start to perform the publishing process and a Job - object is returned. Defaults to False. - - skip_connection_check : bool, default False - Set to True to skip connection check at time of upload. Publishing - will succeed but unchecked connection issues may result in a - non-functioning workbook. Defaults to False. - - Raises - ------ - OSError - If the file path does not lead to an existing file. - - ServerResponseError - If the server response is not successful. - - TypeError - If the file is not a file path or file object. - - ValueError - If the file extension is not supported - - ValueError - If the mode is invalid. - - ValueError - Workbooks cannot be appended. - - Returns - ------- - WorkbookItem | JobItem - The workbook item or job item that was published. - """ if isinstance(file, (str, os.PathLike)): if not os.path.isfile(file): error = "File path does not lead to an existing file." - raise OSError(error) + raise IOError(error) filename = os.path.basename(file) file_extension = os.path.splitext(filename)[1][1:] @@ -784,12 +346,12 @@ def publish( elif file_type == "xml": file_extension = "twb" else: - error = f"Unsupported file type {file_type}!" + error = "Unsupported file type {}!".format(file_type) raise ValueError(error) # Generate filename for file object. # This is needed when publishing the workbook in a single request - filename = f"{workbook_item.name}.{file_extension}" + filename = "{}.{}".format(workbook_item.name, file_extension) file_size = get_file_object_size(file) else: @@ -800,30 +362,30 @@ def publish( raise ValueError(error) # Construct the url with the defined mode - url = f"{self.baseurl}?workbookType={file_extension}" + url = "{0}?workbookType={1}".format(self.baseurl, file_extension) if mode == self.parent_srv.PublishMode.Overwrite: - url += f"&{mode.lower()}=true" + url += "&{0}=true".format(mode.lower()) elif mode == self.parent_srv.PublishMode.Append: error = "Workbooks cannot be appended." raise ValueError(error) if as_job: - url += "&{}=true".format("asJob") + url += "&{0}=true".format("asJob") if skip_connection_check: - url += "&{}=true".format("skipConnectionCheck") + url += "&{0}=true".format("skipConnectionCheck") # Determine if chunking is required (64MB is the limit for single upload method) if file_size >= FILESIZE_LIMIT: - logger.info(f"Publishing {workbook_item.name} to server with chunking method (workbook over 64MB)") + 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 = f"{url}&uploadSessionId={upload_session_id}" + url = "{0}&uploadSessionId={1}".format(url, upload_session_id) xml_request, content_type = RequestFactory.Workbook.publish_req_chunked( workbook_item, connections=connections, ) else: - logger.info(f"Publishing {filename} to server") + logger.info("Publishing {0} to server".format(filename)) if isinstance(file, (str, Path)): with open(file, "rb") as f: @@ -841,7 +403,7 @@ def publish( file_contents, connections=connections, ) - logger.debug(f"Request xml: {redact_xml(xml_request[:1000])} ") + logger.debug("Request xml: {0} ".format(redact_xml(xml_request[:1000]))) # Send the publishing request to server try: @@ -853,38 +415,16 @@ def publish( if as_job: new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Published {workbook_item.name} (JOB_ID: {new_job.id}") + logger.info("Published {0} (JOB_ID: {1}".format(workbook_item.name, new_job.id)) return new_job else: new_workbook = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Published {workbook_item.name} (ID: {new_workbook.id})") + logger.info("Published {0} (ID: {1})".format(workbook_item.name, new_workbook.id)) return new_workbook # Populate workbook item's revisions @api(version="2.3") def populate_revisions(self, workbook_item: WorkbookItem) -> None: - """ - Populates (or gets) a list of revisions for a workbook. - - You must first call this method to populate revisions before you can - iterate through the revisions. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#get_workbook_revisions - - Parameters - ---------- - workbook_item : WorkbookItem - The workbook item to populate revisions for. - - Returns - ------- - None - - Raises - ------ - MissingRequiredFieldError - If the workbook item is missing an ID. - """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -893,12 +433,12 @@ def revisions_fetcher(): return self._get_workbook_revisions(workbook_item) workbook_item._set_revisions(revisions_fetcher) - logger.info(f"Populated revisions for workbook (ID: {workbook_item.id})") + logger.info("Populated revisions for workbook (ID: {0})".format(workbook_item.id)) def _get_workbook_revisions( self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None - ) -> list[RevisionItem]: - url = f"{self.baseurl}/{workbook_item.id}/revisions" + ) -> List[RevisionItem]: + url = "{0}/{1}/revisions".format(self.baseurl, workbook_item.id) server_response = self.get_request(url, req_options) revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, workbook_item) return revisions @@ -912,47 +452,13 @@ def download_revision( filepath: Optional[PathOrFileW] = None, include_extract: bool = True, ) -> PathOrFileW: - """ - Downloads a workbook revision to the specified directory (optional). - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#download_workbook_revision - - Parameters - ---------- - workbook_id : str - The workbook ID. - - revision_number : str | None - The revision number of the workbook. If None, the latest revision is - downloaded. - - filepath : Path or File object, optional - Downloads the file to the location you specify. If no location is - specified, the file is downloaded to the current working directory. - The default is Filepath=None. - - include_extract : bool, default True - Set to False to exclude the extract from the download. The default - is True. - - Returns - ------- - Path or File object - The path to the downloaded workbook or the file object. - - Raises - ------ - ValueError - If the workbook ID is not defined. - """ - if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) if revision_number is None: - url = f"{self.baseurl}/{workbook_id}/content" + url = "{0}/{1}/content".format(self.baseurl, workbook_id) else: - url = f"{self.baseurl}/{workbook_id}/revisions/{revision_number}/content" + url = "{0}/{1}/revisions/{2}/content".format(self.baseurl, workbook_id, revision_number) if not include_extract: url += "?includeExtract=False" @@ -974,129 +480,37 @@ def download_revision( f.write(chunk) return_path = os.path.abspath(download_path) - logger.info(f"Downloaded workbook revision {revision_number} to {return_path} (ID: {workbook_id})") + logger.info( + "Downloaded workbook revision {0} to {1} (ID: {2})".format(revision_number, return_path, workbook_id) + ) return return_path @api(version="2.3") def delete_revision(self, workbook_id: str, revision_number: str) -> None: - """ - Deletes a specific revision from a workbook on Tableau Server. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_revisions.htm#remove_workbook_revision - - Parameters - ---------- - workbook_id : str - The workbook ID. - - revision_number : str - The revision number of the workbook to delete. - - Returns - ------- - None - - Raises - ------ - ValueError - If the workbook ID or revision number is not defined. - """ if workbook_id is None or revision_number is None: raise ValueError url = "/".join([self.baseurl, workbook_id, "revisions", revision_number]) self.delete_request(url) - logger.info(f"Deleted single workbook revision (ID: {workbook_id}) (Revision: {revision_number})") + logger.info("Deleted single workbook revision (ID: {0}) (Revision: {1})".format(workbook_id, revision_number)) # a convenience method @api(version="2.8") def schedule_extract_refresh( self, schedule_id: str, item: WorkbookItem - ) -> list["AddResponse"]: # actually should return a task - """ - Adds a workbook to a schedule for extract refresh. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#add_workbook_to_schedule - - Parameters - ---------- - schedule_id : str - The schedule ID. - - item : WorkbookItem - The workbook item to add to the schedule. - - Returns - ------- - list[AddResponse] - The response from the server. - """ + ) -> List["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, workbook=item) @api(version="1.0") - def add_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> set[str]: - """ - Adds tags to a workbook. One or more tags may be added at a time. If a - tag already exists on the workbook, it will not be duplicated. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#add_tags_to_workbook - - Parameters - ---------- - item : WorkbookItem | str - The workbook item or workbook ID to add tags to. - - tags : Iterable[str] | str - The tag or tags to add to the workbook. Tags can be a single tag or - a list of tags. - - Returns - ------- - set[str] - The set of tags added to the workbook. - """ + def add_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> Set[str]: return super().add_tags(item, tags) @api(version="1.0") def delete_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> None: - """ - Deletes tags from a workbook. One or more tags may be deleted at a time. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#delete_tag_from_workbook - - Parameters - ---------- - item : WorkbookItem | str - The workbook item or workbook ID to delete tags from. - - tags : Iterable[str] | str - The tag or tags to delete from the workbook. Tags can be a single - tag or a list of tags. - - Returns - ------- - None - """ return super().delete_tags(item, tags) @api(version="1.0") def update_tags(self, item: WorkbookItem) -> None: - """ - Updates the tags on a workbook. This method is used to update the tags - on the server to match the tags on the workbook item. This method is a - convenience method that calls add_tags and delete_tags to update the - tags on the server. - - Parameters - ---------- - item : WorkbookItem - The workbook item to update the tags for. The tags on the workbook - item will be used to update the tags on the server. - - Returns - ------- - None - """ return super().update_tags(item) def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[WorkbookItem]: diff --git a/tableauserverclient/server/filter.py b/tableauserverclient/server/filter.py index fd90e281f..b936ceb92 100644 --- a/tableauserverclient/server/filter.py +++ b/tableauserverclient/server/filter.py @@ -1,7 +1,7 @@ from .request_options import RequestOptions -class Filter: +class Filter(object): def __init__(self, field, operator, value): self.field = field self.operator = operator @@ -16,7 +16,7 @@ def __str__(self): # to [,] # so effectively, remove any spaces between "," and "'" and then remove all "'" value_string = value_string.replace(", '", ",'").replace("'", "") - return f"{self.field}:{self.operator}:{value_string}" + return "{0}:{1}:{2}".format(self.field, self.operator, value_string) @property def value(self): diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index e6d261b61..ca9d83872 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -1,7 +1,6 @@ import copy from functools import partial -from typing import Optional, Protocol, TypeVar, Union, runtime_checkable -from collections.abc import Iterable, Iterator +from typing import Iterable, Iterator, List, Optional, Protocol, Tuple, TypeVar, Union, runtime_checkable from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.request_options import RequestOptions @@ -12,12 +11,14 @@ @runtime_checkable class Endpoint(Protocol[T]): - def get(self, req_options: Optional[RequestOptions]) -> tuple[list[T], PaginationItem]: ... + def get(self, req_options: Optional[RequestOptions]) -> Tuple[List[T], PaginationItem]: + ... @runtime_checkable class CallableEndpoint(Protocol[T]): - def __call__(self, __req_options: Optional[RequestOptions], **kwargs) -> tuple[list[T], PaginationItem]: ... + def __call__(self, __req_options: Optional[RequestOptions], **kwargs) -> Tuple[List[T], PaginationItem]: + ... class Pager(Iterable[T]): @@ -26,7 +27,7 @@ class Pager(Iterable[T]): Supports all `RequestOptions` including starting on any page. Also used by models to load sub-models (users in a group, views in a workbook, etc) by passing a different endpoint. - Will loop over anything that returns (list[ModelItem], PaginationItem). + Will loop over anything that returns (List[ModelItem], PaginationItem). """ def __init__( diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index 801ad4a13..bbca612e9 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -1,10 +1,8 @@ -from collections.abc import Iterable, Iterator, Sized +from collections.abc import Sized from itertools import count -from typing import Optional, Protocol, TYPE_CHECKING, TypeVar, overload -import sys +from typing import Iterable, Iterator, List, Optional, Protocol, Tuple, TYPE_CHECKING, TypeVar, overload from tableauserverclient.config import config from tableauserverclient.models.pagination_item import PaginationItem -from tableauserverclient.server.endpoint.exceptions import ServerResponseError from tableauserverclient.server.filter import Filter from tableauserverclient.server.request_options import RequestOptions from tableauserverclient.server.sort import Sort @@ -36,36 +34,10 @@ def to_camel_case(word: str) -> str: class QuerySet(Iterable[T], Sized): - """ - QuerySet is a class that allows easy filtering, sorting, and iterating over - many endpoints in TableauServerClient. It is designed to be used in a similar - way to Django QuerySets, but with a more limited feature set. - - QuerySet is an iterable, and can be used in for loops, list comprehensions, - and other places where iterables are expected. - - QuerySet is also Sized, and can be used in places where the length of the - QuerySet is needed. The length of the QuerySet is the total number of items - available in the QuerySet, not just the number of items that have been - fetched. If the endpoint does not return a total count of items, the length - of the QuerySet will be sys.maxsize. If there is no total count, the - QuerySet will continue to fetch items until there are no more items to - fetch. - - QuerySet is not re-entrant. It is not designed to be used in multiple places - at the same time. If you need to use a QuerySet in multiple places, you - should create a new QuerySet for each place you need to use it, convert it - to a list, or create a deep copy of the QuerySet. - - QuerySets are also indexable, and can be sliced. If you try to access an - index that has not been fetched, the QuerySet will fetch the page that - contains the item you are looking for. - """ - def __init__(self, model: "QuerysetEndpoint[T]", page_size: Optional[int] = None) -> None: self.model = model self.request_options = RequestOptions(pagesize=page_size or config.PAGE_SIZE) - self._result_cache: list[T] = [] + self._result_cache: List[T] = [] self._pagination_item = PaginationItem() def __iter__(self: Self) -> Iterator[T]: @@ -77,30 +49,19 @@ def __iter__(self: Self) -> Iterator[T]: for page in count(1): self.request_options.pagenumber = page self._result_cache = [] - self._pagination_item._page_number = None - try: - self._fetch_all() - except ServerResponseError as e: - if e.code == "400006": - # If the endpoint does not support pagination, it will end - # up overrunning the total number of pages. Catch the - # error and break out of the loop. - raise StopIteration - if len(self._result_cache) == 0: - return + self._fetch_all() yield from self._result_cache - # If the length of the QuerySet is unknown, continue fetching until - # the result cache is empty. - if (size := len(self)) == 0: - continue - if (page * self.page_size) >= size: + # Set result_cache to empty so the fetch will populate + if (page * self.page_size) >= len(self): return @overload - def __getitem__(self: Self, k: Slice) -> list[T]: ... + def __getitem__(self: Self, k: Slice) -> List[T]: + ... @overload - def __getitem__(self: Self, k: int) -> T: ... + def __getitem__(self: Self, k: int) -> T: + ... def __getitem__(self, k): page = self.page_number @@ -142,7 +103,6 @@ def __getitem__(self, k): elif k in range(self.total_available): # Otherwise, check if k is even sensible to return self._result_cache = [] - self._pagination_item._page_number = None # 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] @@ -154,16 +114,11 @@ def _fetch_all(self: Self) -> None: """ Retrieve the data and store result and pagination item in cache """ - if not self._result_cache and self._pagination_item._page_number is None: - response = self.model.get(self.request_options) - if isinstance(response, tuple): - self._result_cache, self._pagination_item = response - else: - self._result_cache = response - self._pagination_item = PaginationItem() + if not self._result_cache: + self._result_cache, self._pagination_item = self.model.get(self.request_options) def __len__(self: Self) -> int: - return sys.maxsize if self.total_available is None else self.total_available + return self.total_available @property def total_available(self: Self) -> int: @@ -173,16 +128,12 @@ def total_available(self: Self) -> int: @property def page_number(self: Self) -> int: self._fetch_all() - # If the PaginationItem is not returned from the endpoint, use the - # pagenumber from the RequestOptions. - return self._pagination_item.page_number or self.request_options.pagenumber + return self._pagination_item.page_number @property def page_size(self: Self) -> int: self._fetch_all() - # If the PaginationItem is not returned from the endpoint, use the - # pagesize from the RequestOptions. - return self._pagination_item.page_size or self.request_options.pagesize + return self._pagination_item.page_size def filter(self: Self, *invalid, page_size: Optional[int] = None, **kwargs) -> Self: if invalid: @@ -209,22 +160,22 @@ def paginate(self: Self, **kwargs) -> Self: return self @staticmethod - def _parse_shorthand_filter(key: str) -> tuple[str, str]: + def _parse_shorthand_filter(key: str) -> Tuple[str, str]: tokens = key.split("__", 1) if len(tokens) == 1: operator = RequestOptions.Operator.Equals else: operator = tokens[1] if operator not in RequestOptions.Operator.__dict__.values(): - raise ValueError(f"Operator `{operator}` is not valid.") + raise ValueError("Operator `{}` is not valid.".format(operator)) field = to_camel_case(tokens[0]) if field not in RequestOptions.Field.__dict__.values(): - raise ValueError(f"Field name `{field}` is not valid.") + raise ValueError("Field name `{}` is not valid.".format(field)) return (field, operator) @staticmethod - def _parse_shorthand_sort(key: str) -> tuple[str, str]: + def _parse_shorthand_sort(key: str) -> Tuple[str, str]: direction = RequestOptions.Direction.Asc if key.startswith("-"): direction = RequestOptions.Direction.Desc diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index f7bd139d7..96fa14680 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1,6 +1,5 @@ import xml.etree.ElementTree as ET -from typing import Any, Callable, Optional, TypeVar, TYPE_CHECKING, Union -from collections.abc import Iterable +from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, TypeVar, TYPE_CHECKING, Union from typing_extensions import ParamSpec @@ -16,7 +15,7 @@ # this file could be largely replaced if we were willing to import the huge file from generateDS -def _add_multipart(parts: dict) -> tuple[Any, str]: +def _add_multipart(parts: Dict) -> Tuple[Any, str]: mime_multipart_parts = list() for name, (filename, data, content_type) in parts.items(): multipart_part = RequestField(name=name, data=data, filename=filename) @@ -81,7 +80,7 @@ def _add_credentials_element(parent_element, connection_credentials): credentials_element.attrib["oAuth"] = "true" -class AuthRequest: +class AuthRequest(object): def signin_req(self, auth_item): xml_request = ET.Element("tsRequest") @@ -105,7 +104,7 @@ def switch_req(self, site_content_url): return ET.tostring(xml_request) -class ColumnRequest: +class ColumnRequest(object): def update_req(self, column_item): xml_request = ET.Element("tsRequest") column_element = ET.SubElement(xml_request, "column") @@ -116,7 +115,7 @@ def update_req(self, column_item): return ET.tostring(xml_request) -class DataAlertRequest: +class DataAlertRequest(object): def add_user_to_alert(self, alert_item: "DataAlertItem", user_id: str) -> bytes: xml_request = ET.Element("tsRequest") user_element = ET.SubElement(xml_request, "user") @@ -141,7 +140,7 @@ def update_req(self, alert_item: "DataAlertItem") -> bytes: return ET.tostring(xml_request) -class DatabaseRequest: +class DatabaseRequest(object): def update_req(self, database_item): xml_request = ET.Element("tsRequest") database_element = ET.SubElement(xml_request, "database") @@ -160,7 +159,7 @@ def update_req(self, database_item): return ET.tostring(xml_request) -class DatasourceRequest: +class DatasourceRequest(object): def _generate_xml(self, datasource_item: DatasourceItem, connection_credentials=None, connections=None): xml_request = ET.Element("tsRequest") datasource_element = ET.SubElement(xml_request, "datasource") @@ -245,7 +244,7 @@ def publish_req_chunked(self, datasource_item, connection_credentials=None, conn return _add_multipart(parts) -class DQWRequest: +class DQWRequest(object): def add_req(self, dqw_item): xml_request = ET.Element("tsRequest") dqw_element = ET.SubElement(xml_request, "dataQualityWarning") @@ -275,7 +274,7 @@ def update_req(self, dqw_item): return ET.tostring(xml_request) -class FavoriteRequest: +class FavoriteRequest(object): def add_request(self, id_: Optional[str], target_type: str, label: Optional[str]) -> bytes: """ @@ -330,7 +329,7 @@ def add_workbook_req(self, id_: Optional[str], name: Optional[str]) -> bytes: return self.add_request(id_, Resource.Workbook, name) -class FileuploadRequest: +class FileuploadRequest(object): def chunk_req(self, chunk): parts = { "request_payload": ("", "", "text/xml"), @@ -339,8 +338,8 @@ def chunk_req(self, chunk): return _add_multipart(parts) -class FlowRequest: - def _generate_xml(self, flow_item: "FlowItem", connections: Optional[list["ConnectionItem"]] = None) -> bytes: +class FlowRequest(object): + def _generate_xml(self, flow_item: "FlowItem", connections: Optional[List["ConnectionItem"]] = None) -> bytes: xml_request = ET.Element("tsRequest") flow_element = ET.SubElement(xml_request, "flow") if flow_item.name is not None: @@ -371,8 +370,8 @@ def publish_req( flow_item: "FlowItem", filename: str, file_contents: bytes, - connections: Optional[list["ConnectionItem"]] = None, - ) -> tuple[Any, str]: + connections: Optional[List["ConnectionItem"]] = None, + ) -> Tuple[Any, str]: xml_request = self._generate_xml(flow_item, connections) parts = { @@ -381,14 +380,14 @@ def publish_req( } return _add_multipart(parts) - def publish_req_chunked(self, flow_item, connections=None) -> tuple[Any, str]: + def publish_req_chunked(self, flow_item, connections=None) -> Tuple[Any, str]: xml_request = self._generate_xml(flow_item, connections) parts = {"request_payload": ("", xml_request, "text/xml")} return _add_multipart(parts) -class GroupRequest: +class GroupRequest(object): def add_user_req(self, user_id: str) -> bytes: xml_request = ET.Element("tsRequest") user_element = ET.SubElement(xml_request, "user") @@ -478,7 +477,7 @@ def update_req( return ET.tostring(xml_request) -class PermissionRequest: +class PermissionRequest(object): def add_req(self, rules: Iterable[PermissionsRule]) -> bytes: xml_request = ET.Element("tsRequest") permissions_element = ET.SubElement(xml_request, "permissions") @@ -500,7 +499,7 @@ def _add_all_capabilities(self, capabilities_element, capabilities_map): capability_element.attrib["mode"] = mode -class ProjectRequest: +class ProjectRequest(object): def update_req(self, project_item: "ProjectItem") -> bytes: xml_request = ET.Element("tsRequest") project_element = ET.SubElement(xml_request, "project") @@ -531,7 +530,7 @@ def create_req(self, project_item: "ProjectItem") -> bytes: return ET.tostring(xml_request) -class ScheduleRequest: +class ScheduleRequest(object): def create_req(self, schedule_item): xml_request = ET.Element("tsRequest") schedule_element = ET.SubElement(xml_request, "schedule") @@ -610,7 +609,7 @@ def add_flow_req(self, id_: Optional[str], task_type: str = TaskItem.Type.RunFlo return self._add_to_req(id_, "flow", task_type) -class SiteRequest: +class SiteRequest(object): def update_req(self, site_item: "SiteItem", parent_srv: Optional["Server"] = None): xml_request = ET.Element("tsRequest") site_element = ET.SubElement(xml_request, "site") @@ -849,7 +848,7 @@ def set_versioned_flow_attributes(self, flows_all, flows_edit, flows_schedule, p warnings.warn("In version 3.10 and earlier there is only one option: FlowsEnabled") -class TableRequest: +class TableRequest(object): def update_req(self, table_item): xml_request = ET.Element("tsRequest") table_element = ET.SubElement(xml_request, "table") @@ -872,7 +871,7 @@ def update_req(self, table_item): content_types = Iterable[Union["ColumnItem", "DatabaseItem", "DatasourceItem", "FlowItem", "TableItem", "WorkbookItem"]] -class TagRequest: +class TagRequest(object): def add_req(self, tag_set): xml_request = ET.Element("tsRequest") tags_element = ET.SubElement(xml_request, "tags") @@ -882,7 +881,7 @@ def add_req(self, tag_set): return ET.tostring(xml_request) @_tsrequest_wrapped - def batch_create(self, element: ET.Element, tags: set[str], content: content_types) -> bytes: + def batch_create(self, element: ET.Element, tags: Set[str], content: content_types) -> bytes: tag_batch = ET.SubElement(element, "tagBatch") tags_element = ET.SubElement(tag_batch, "tags") for tag in tags: @@ -898,7 +897,7 @@ def batch_create(self, element: ET.Element, tags: set[str], content: content_typ return ET.tostring(element) -class UserRequest: +class UserRequest(object): def update_req(self, user_item: UserItem, password: Optional[str]) -> bytes: xml_request = ET.Element("tsRequest") user_element = ET.SubElement(xml_request, "user") @@ -932,7 +931,7 @@ def add_req(self, user_item: UserItem) -> bytes: return ET.tostring(xml_request) -class WorkbookRequest: +class WorkbookRequest(object): def _generate_xml( self, workbook_item, @@ -996,9 +995,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.") @@ -1076,7 +1075,7 @@ def embedded_extract_req( datasource_element.attrib["id"] = id_ -class Connection: +class Connection(object): @_tsrequest_wrapped def update_req(self, xml_request: ET.Element, connection_item: "ConnectionItem") -> None: connection_element = ET.SubElement(xml_request, "connection") @@ -1099,7 +1098,7 @@ def update_req(self, xml_request: ET.Element, connection_item: "ConnectionItem") connection_element.attrib["queryTaggingEnabled"] = str(connection_item.query_tagging).lower() -class TaskRequest: +class TaskRequest(object): @_tsrequest_wrapped def run_req(self, xml_request: ET.Element, task_item: Any) -> None: # Send an empty tsRequest @@ -1138,7 +1137,7 @@ def create_extract_req(self, xml_request: ET.Element, extract_item: "TaskItem") return ET.tostring(xml_request) -class FlowTaskRequest: +class FlowTaskRequest(object): @_tsrequest_wrapped def create_flow_task_req(self, xml_request: ET.Element, flow_item: "TaskItem") -> bytes: flow_element = ET.SubElement(xml_request, "runFlow") @@ -1172,7 +1171,7 @@ def create_flow_task_req(self, xml_request: ET.Element, flow_item: "TaskItem") - return ET.tostring(xml_request) -class SubscriptionRequest: +class SubscriptionRequest(object): @_tsrequest_wrapped def create_req(self, xml_request: ET.Element, subscription_item: "SubscriptionItem") -> bytes: subscription_element = ET.SubElement(xml_request, "subscription") @@ -1236,13 +1235,13 @@ def update_req(self, xml_request: ET.Element, subscription_item: "SubscriptionIt return ET.tostring(xml_request) -class EmptyRequest: +class EmptyRequest(object): @_tsrequest_wrapped def empty_req(self, xml_request: ET.Element) -> None: pass -class WebhookRequest: +class WebhookRequest(object): @_tsrequest_wrapped def create_req(self, xml_request: ET.Element, webhook_item: "WebhookItem") -> bytes: webhook = ET.SubElement(xml_request, "webhook") @@ -1288,7 +1287,7 @@ def update_req(self, xml_request: ET.Element, metric_item: MetricItem) -> bytes: return ET.tostring(xml_request) -class CustomViewRequest: +class CustomViewRequest(object): @_tsrequest_wrapped def update_req(self, xml_request: ET.Element, custom_view_item: CustomViewItem): updating_element = ET.SubElement(xml_request, "customView") @@ -1416,7 +1415,7 @@ def publish(self, xml_request: ET.Element, virtual_connection: VirtualConnection return ET.tostring(xml_request) -class RequestFactory: +class RequestFactory(object): Auth = AuthRequest() Connection = Connection() Column = ColumnRequest() diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index d79ac7f73..ddb45834d 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,5 +1,4 @@ import sys -from typing import Optional from typing_extensions import Self @@ -10,12 +9,12 @@ from tableauserverclient.helpers.logging import logger -class RequestOptionsBase: +class RequestOptionsBase(object): # This method is used if server api version is below 3.7 (2020.1) def apply_query_params(self, url): try: params = self.get_query_params() - params_list = [f"{k}={v}" for (k, v) in params.items()] + params_list = ["{}={}".format(k, v) for (k, v) in params.items()] logger.debug("Applying options to request: <%s(%s)>", self.__class__.__name__, ",".join(params_list)) @@ -23,52 +22,15 @@ def apply_query_params(self, url): url, existing_params = url.split("?") params_list.append(existing_params) - return "{}?{}".format(url, "&".join(params_list)) + return "{0}?{1}".format(url, "&".join(params_list)) except NotImplementedError: raise - -# If it wasn't a breaking change, I'd rename it to QueryOptions -""" -This class manages options can be used when querying content on the server -""" + def get_query_params(self): + raise NotImplementedError() class RequestOptions(RequestOptionsBase): - def __init__(self, pagenumber=1, pagesize=None): - self.pagenumber = pagenumber - self.pagesize = pagesize or config.PAGE_SIZE - self.sort = set() - self.filter = set() - # This is private until we expand all of our parsers to handle the extra fields - self._all_fields = False - - def get_query_params(self) -> dict: - params = {} - if self.sort and len(self.sort) > 0: - sort_options = (str(sort_item) for sort_item in self.sort) - ordered_sort_options = sorted(sort_options) - params["sort"] = ",".join(ordered_sort_options) - if len(self.filter) > 0: - filter_options = (str(filter_item) for filter_item in self.filter) - ordered_filter_options = sorted(filter_options) - params["filter"] = ",".join(ordered_filter_options) - if self._all_fields: - params["fields"] = "_all_" - if self.pagenumber: - params["pageNumber"] = self.pagenumber - if self.pagesize: - params["pageSize"] = self.pagesize - return params - - def page_size(self, page_size): - self.pagesize = page_size - return self - - def page_number(self, page_number): - self.pagenumber = page_number - return self - class Operator: Equals = "eq" GreaterThan = "gt" @@ -79,7 +41,6 @@ class Operator: Has = "has" CaseInsensitiveEquals = "cieq" - # These are fields in the REST API class Field: Args = "args" AuthenticationType = "authenticationType" @@ -156,53 +117,60 @@ class Direction: Desc = "desc" Asc = "asc" + def __init__(self, pagenumber=1, pagesize=None): + self.pagenumber = pagenumber + self.pagesize = pagesize or config.PAGE_SIZE + self.sort = set() + self.filter = set() -""" -These options can be used by methods that are fetching data exported from a specific content item -""" - - -class _DataExportOptions(RequestOptionsBase): - def __init__(self, maxage: int = -1): - super().__init__() - self.view_filters: list[tuple[str, str]] = [] - self.view_parameters: list[tuple[str, str]] = [] - self.max_age: Optional[int] = maxage - """ - This setting will affect the contents of the workbook as they are exported. - Valid language values are tableau-supported languages like de, es, en - If no locale is specified, the default locale for that language will be used - """ - self.language: Optional[str] = None + # This is private until we expand all of our parsers to handle the extra fields + self._all_fields = False - @property - def max_age(self) -> int: - return self._max_age + def page_size(self, page_size): + self.pagesize = page_size + return self - @max_age.setter - @property_is_int(range=(0, 240), allowed=[-1]) - def max_age(self, value): - self._max_age = value + def page_number(self, page_number): + self.pagenumber = page_number + return self def get_query_params(self): params = {} - if self.max_age != -1: - params["maxAge"] = self.max_age - if self.language: - params["language"] = self.language - - self._append_view_filters(params) + if self.pagenumber: + params["pageNumber"] = self.pagenumber + if self.pagesize: + params["pageSize"] = self.pagesize + if len(self.sort) > 0: + sort_options = (str(sort_item) for sort_item in self.sort) + ordered_sort_options = sorted(sort_options) + params["sort"] = ",".join(ordered_sort_options) + if len(self.filter) > 0: + filter_options = (str(filter_item) for filter_item in self.filter) + ordered_filter_options = sorted(filter_options) + params["filter"] = ",".join(ordered_filter_options) + if self._all_fields: + params["fields"] = "_all_" return params + +class _FilterOptionsBase(RequestOptionsBase): + """Provide a basic implementation of adding view filters to the url""" + + def __init__(self): + self.view_filters = [] + self.view_parameters = [] + + def get_query_params(self): + raise NotImplementedError() + def vf(self, name: str, value: str) -> Self: - """Apply a filter based on a column within the view. - Note that when filtering on a boolean type field, the only valid values are 'true' and 'false'""" + """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 parameter(self, name: str, value: str) -> Self: - """Apply a filter based on a parameter within the workbook. - Note that when filtering on a boolean type field, the only valid values are 'true' and 'false'""" + """Apply a filter based on a parameter within the workbook.""" self.view_parameters.append((name, value)) return self @@ -213,73 +181,82 @@ def _append_view_filters(self, params) -> None: params[name] = value -class _ImagePDFCommonExportOptions(_DataExportOptions): - def __init__(self, maxage=-1, viz_height=None, viz_width=None): - super().__init__(maxage=maxage) - self.viz_height = viz_height - self.viz_width = viz_width +class CSVRequestOptions(_FilterOptionsBase): + def __init__(self, maxage=-1): + super(CSVRequestOptions, self).__init__() + self.max_age = maxage @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) -> dict: - params = super().get_query_params() - - # 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") + def max_age(self): + return self._max_age - if self.viz_height is not None: - params["vizHeight"] = self.viz_height + @max_age.setter + @property_is_int(range=(0, 240), allowed=[-1]) + def max_age(self, value): + self._max_age = value - if self.viz_width is not None: - params["vizWidth"] = self.viz_width + def get_query_params(self): + params = {} + if self.max_age != -1: + params["maxAge"] = self.max_age + self._append_view_filters(params) return params -class CSVRequestOptions(_DataExportOptions): - extension = "csv" +class ExcelRequestOptions(_FilterOptionsBase): + def __init__(self, maxage: int = -1) -> None: + super().__init__() + self.max_age = maxage + @property + def max_age(self) -> int: + return self._max_age + + @max_age.setter + @property_is_int(range=(0, 240), allowed=[-1]) + def max_age(self, value: int) -> None: + self._max_age = value -class ExcelRequestOptions(_DataExportOptions): - extension = "xlsx" + def get_query_params(self): + params = {} + if self.max_age != -1: + params["maxAge"] = self.max_age + self._append_view_filters(params) + return params -class ImageRequestOptions(_ImagePDFCommonExportOptions): - extension = "png" +class ImageRequestOptions(_FilterOptionsBase): # if 'high' isn't specified, the REST API endpoint returns an image with standard resolution class Resolution: High = "high" - def __init__(self, imageresolution=None, maxage=-1, viz_height=None, viz_width=None): - super().__init__(maxage=maxage, viz_height=viz_height, viz_width=viz_width) + def __init__(self, imageresolution=None, maxage=-1): + super(ImageRequestOptions, self).__init__() self.image_resolution = imageresolution + self.max_age = maxage + + @property + def max_age(self): + return self._max_age + + @max_age.setter + @property_is_int(range=(0, 240), allowed=[-1]) + def max_age(self, value): + self._max_age = value def get_query_params(self): - params = super().get_query_params() + params = {} if self.image_resolution: params["resolution"] = self.image_resolution + if self.max_age != -1: + params["maxAge"] = self.max_age + self._append_view_filters(params) return params -class PDFRequestOptions(_ImagePDFCommonExportOptions): +class PDFRequestOptions(_FilterOptionsBase): class PageType: A3 = "a3" A4 = "a4" @@ -301,16 +278,61 @@ class Orientation: Landscape = "landscape" def __init__(self, page_type=None, orientation=None, maxage=-1, viz_height=None, viz_width=None): - super().__init__(maxage=maxage, viz_height=viz_height, viz_width=viz_width) + 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): + return self._max_age + + @max_age.setter + @property_is_int(range=(0, 240), allowed=[-1]) + 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) -> dict: - params = super().get_query_params() + def get_query_params(self): + params = {} if self.page_type: params["type"] = self.page_type if self.orientation: params["orientation"] = self.orientation + 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") + + 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/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 4eeefcaf9..e563a7138 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -58,64 +58,8 @@ default_server_version = "2.4" # first version that dropped the legacy auth endpoint -class Server: - """ - In the Tableau REST API, the server (https://MY-SERVER/) is the base or core - of the URI that makes up the various endpoints or methods for accessing - resources on the server (views, workbooks, sites, users, data sources, etc.) - The TSC library provides a Server class that represents the server. You - create a server instance to sign in to the server and to call the various - methods for accessing resources. - - The Server class contains the attributes that represent the server on - Tableau Server. After you create an instance of the Server class, you can - sign in to the server and call methods to access all of the resources on the - server. - - Parameters - ---------- - server_address : str - Specifies the address of the Tableau Server or Tableau Cloud (for - example, https://MY-SERVER/). - - use_server_version : bool - Specifies the version of the REST API to use (for example, '2.5'). When - you use the TSC library to call methods that access Tableau Server, the - version is passed to the endpoint as part of the URI - (https://MY-SERVER/api/2.5/). Each release of Tableau Server supports - specific versions of the REST API. New versions of the REST API are - released with Tableau Server. By default, the value of version is set to - '2.3', which corresponds to Tableau Server 10.0. You can view or set - this value. You might need to set this to a different value, for - example, if you want to access features that are supported by the server - and a later version of the REST API. For more information, see REST API - Versions. - - Examples - -------- - >>> import tableauserverclient as TSC - - >>> # create a instance of server - >>> server = TSC.Server('https://MY-SERVER') - - >>> # sign in, etc. - - >>> # change the REST API version to match the server - >>> server.use_server_version() - - >>> # or change the REST API version to match a specific version - >>> # for example, 2.8 - >>> # server.version = '2.8' - - """ - +class Server(object): class PublishMode: - """ - Enumerates the options that specify what happens when you publish a - workbook or data source. The options are Overwrite, Append, or - CreateNew. - """ - Append = "Append" Overwrite = "Overwrite" CreateNew = "CreateNew" @@ -186,7 +130,7 @@ def validate_connection_settings(self): raise ValueError("Server connection settings not valid", req_ex) def __repr__(self): - return f"" + return "".format(self.baseurl, self.server_info.serverInfo) def add_http_options(self, options_dict: dict): try: @@ -198,7 +142,7 @@ def add_http_options(self, options_dict: dict): # expected errors on invalid input: # 'set' object has no attribute 'keys', 'list' object has no attribute 'keys' # TypeError: cannot convert dictionary update sequence element #0 to a sequence (input is a tuple) - raise ValueError(f"Invalid http options given: {options_dict}") + raise ValueError("Invalid http options given: {}".format(options_dict)) def clear_http_options(self): self._http_options = dict() @@ -232,15 +176,15 @@ def _determine_highest_version(self): old_version = self.version version = self.server_info.get().rest_api_version except ServerInfoEndpointNotFoundError as e: - logger.info(f"Could not get version info from server: {e.__class__}{e}") + logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) version = self._get_legacy_version() except EndpointUnavailableError as e: - logger.info(f"Could not get version info from server: {e.__class__}{e}") + logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) version = self._get_legacy_version() except Exception as e: - logger.info(f"Could not get version info from server: {e.__class__}{e}") + logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) version = None - logger.info(f"versions: {version}, {old_version}") + logger.info("versions: {}, {}".format(version, old_version)) return version or old_version def use_server_version(self): @@ -257,12 +201,12 @@ def check_at_least_version(self, target: str): def assert_at_least_version(self, comparison: str, reason: str): if not self.check_at_least_version(comparison): - error = f"{reason} is not available in API version {self.version}. Requires {comparison}" + error = "{} is not available in API version {}. Requires {}".format(reason, self.version, comparison) raise EndpointUnavailableError(error) @property def baseurl(self): - return f"{self._server_address}/api/{str(self.version)}" + return "{0}/api/{1}".format(self._server_address, str(self.version)) @property def namespace(self): diff --git a/tableauserverclient/server/sort.py b/tableauserverclient/server/sort.py index 839a8c8db..2d6bc030a 100644 --- a/tableauserverclient/server/sort.py +++ b/tableauserverclient/server/sort.py @@ -1,7 +1,7 @@ -class Sort: +class Sort(object): def __init__(self, field, direction): self.field = field self.direction = direction def __str__(self): - return f"{self.field}:{self.direction}" + return "{0}:{1}".format(self.field, self.direction) diff --git a/test/_utils.py b/test/_utils.py index b4ee93bc3..8527aaf8c 100644 --- a/test/_utils.py +++ b/test/_utils.py @@ -1,6 +1,5 @@ import os.path import unittest -from xml.etree import ElementTree as ET from contextlib import contextmanager TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -19,19 +18,6 @@ def read_xml_assets(*args): return map(read_xml_asset, args) -def server_response_error_factory(code: str, summary: str, detail: str) -> str: - root = ET.Element("tsResponse") - error = ET.SubElement(root, "error") - error.attrib["code"] = code - - summary_element = ET.SubElement(error, "summary") - summary_element.text = summary - - detail_element = ET.SubElement(error, "detail") - detail_element.text = detail - return ET.tostring(root, encoding="utf-8").decode("utf-8") - - @contextmanager def mocked_time(): mock_time = 0 diff --git a/test/assets/flow_runs_get.xml b/test/assets/flow_runs_get.xml index 489e8ac63..bdce4cdfb 100644 --- a/test/assets/flow_runs_get.xml +++ b/test/assets/flow_runs_get.xml @@ -1,4 +1,5 @@ + - + \ No newline at end of file diff --git a/test/assets/server_info_wrong_site.html b/test/assets/server_info_wrong_site.html deleted file mode 100644 index e92daeb2d..000000000 --- a/test/assets/server_info_wrong_site.html +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - Example website - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- ABCDE
12345
23456
34567
45678
56789
- - - \ No newline at end of file diff --git a/test/test_auth.py b/test/test_auth.py index 48100ad88..eaf13481e 100644 --- a/test/test_auth.py +++ b/test/test_auth.py @@ -63,7 +63,7 @@ def test_sign_in_error(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.TableauAuth("testuser", "wrongpassword") - self.assertRaises(TSC.FailedSignInError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) def test_sign_in_invalid_token(self): with open(SIGN_IN_ERROR_XML, "rb") as f: @@ -71,7 +71,7 @@ def test_sign_in_invalid_token(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.PersonalAccessTokenAuth(token_name="mytoken", personal_access_token="invalid") - self.assertRaises(TSC.FailedSignInError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) def test_sign_in_without_auth(self): with open(SIGN_IN_ERROR_XML, "rb") as f: @@ -79,7 +79,7 @@ def test_sign_in_without_auth(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.TableauAuth("", "") - self.assertRaises(TSC.FailedSignInError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) def test_sign_out(self): with open(SIGN_IN_XML, "rb") as f: diff --git a/test/test_custom_view.py b/test/test_custom_view.py index 6e863a863..80800c86b 100644 --- a/test/test_custom_view.py +++ b/test/test_custom_view.py @@ -18,8 +18,6 @@ GET_XML_ID = os.path.join(TEST_ASSET_DIR, "custom_view_get_id.xml") POPULATE_PREVIEW_IMAGE = os.path.join(TEST_ASSET_DIR, "Sample View Image.png") CUSTOM_VIEW_UPDATE_XML = os.path.join(TEST_ASSET_DIR, "custom_view_update.xml") -CUSTOM_VIEW_POPULATE_PDF = os.path.join(TEST_ASSET_DIR, "populate_pdf.pdf") -CUSTOM_VIEW_POPULATE_CSV = os.path.join(TEST_ASSET_DIR, "populate_csv.csv") CUSTOM_VIEW_DOWNLOAD = TEST_ASSET_DIR / "custom_view_download.json" FILE_UPLOAD_INIT = TEST_ASSET_DIR / "fileupload_initialize.xml" FILE_UPLOAD_APPEND = TEST_ASSET_DIR / "fileupload_append.xml" @@ -248,73 +246,3 @@ def test_large_publish(self): assert isinstance(view, TSC.CustomViewItem) assert view.id is not None assert view.name is not None - - def test_populate_pdf(self) -> None: - self.server.version = "3.23" - self.baseurl = self.server.custom_views.baseurl - with open(CUSTOM_VIEW_POPULATE_PDF, "rb") as f: - response = f.read() - with requests_mock.mock() as m: - m.get( - self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/pdf?type=letter&orientation=portrait&maxAge=5", - content=response, - ) - custom_view = TSC.CustomViewItem() - custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" - - size = TSC.PDFRequestOptions.PageType.Letter - orientation = TSC.PDFRequestOptions.Orientation.Portrait - req_option = TSC.PDFRequestOptions(size, orientation, 5) - - self.server.custom_views.populate_pdf(custom_view, req_option) - self.assertEqual(response, custom_view.pdf) - - def test_populate_csv(self) -> None: - self.server.version = "3.23" - self.baseurl = self.server.custom_views.baseurl - with open(CUSTOM_VIEW_POPULATE_CSV, "rb") as f: - response = f.read() - with requests_mock.mock() as m: - m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/data?maxAge=1", content=response) - custom_view = TSC.CustomViewItem() - custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" - request_option = TSC.CSVRequestOptions(maxage=1) - self.server.custom_views.populate_csv(custom_view, request_option) - - csv_file = b"".join(custom_view.csv) - self.assertEqual(response, csv_file) - - def test_populate_csv_default_maxage(self) -> None: - self.server.version = "3.23" - self.baseurl = self.server.custom_views.baseurl - with open(CUSTOM_VIEW_POPULATE_CSV, "rb") as f: - response = f.read() - with requests_mock.mock() as m: - m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/data", content=response) - custom_view = TSC.CustomViewItem() - custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" - self.server.custom_views.populate_csv(custom_view) - - csv_file = b"".join(custom_view.csv) - self.assertEqual(response, csv_file) - - def test_pdf_height(self) -> None: - self.server.version = "3.23" - self.baseurl = self.server.custom_views.baseurl - with open(CUSTOM_VIEW_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, - ) - custom_view = TSC.CustomViewItem() - custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" - - req_option = TSC.PDFRequestOptions( - viz_height=1080, - viz_width=1920, - ) - - self.server.custom_views.populate_pdf(custom_view, req_option) - self.assertEqual(response, custom_view.pdf) diff --git a/test/test_dataalert.py b/test/test_dataalert.py index 6f6f1683c..d9e00a9db 100644 --- a/test/test_dataalert.py +++ b/test/test_dataalert.py @@ -108,5 +108,5 @@ def test_delete_user_from_alert(self) -> None: alert_id = "5ea59b45-e497-5673-8809-bfe213236f75" user_id = "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" with requests_mock.mock() as m: - m.delete(self.baseurl + f"/{alert_id}/users/{user_id}", status_code=204) + m.delete(self.baseurl + "/{0}/users/{1}".format(alert_id, user_id), status_code=204) self.server.data_alerts.delete_user_from_alert(alert_id, user_id) diff --git a/test/test_datasource.py b/test/test_datasource.py index 45d9ba9c9..624eb93e1 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -75,7 +75,7 @@ def test_get(self) -> None: self.assertEqual("Sample datasource", all_datasources[1].name) self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", all_datasources[1].project_id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_datasources[1].owner_id) - self.assertEqual({"world", "indicators", "sample"}, all_datasources[1].tags) + self.assertEqual(set(["world", "indicators", "sample"]), all_datasources[1].tags) self.assertEqual("https://page.com", all_datasources[1].webpage_url) self.assertTrue(all_datasources[1].encrypt_extracts) self.assertFalse(all_datasources[1].has_extracts) @@ -110,7 +110,7 @@ def test_get_by_id(self) -> None: self.assertEqual("Sample datasource", single_datasource.name) self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", single_datasource.project_id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_datasource.owner_id) - self.assertEqual({"world", "indicators", "sample"}, single_datasource.tags) + self.assertEqual(set(["world", "indicators", "sample"]), single_datasource.tags) self.assertEqual(TSC.DatasourceItem.AskDataEnablement.SiteDefault, single_datasource.ask_data_enablement) def test_update(self) -> None: @@ -488,7 +488,7 @@ def test_download_object(self) -> None: def test_download_sanitizes_name(self) -> None: filename = "Name,With,Commas.tds" - disposition = f'name="tableau_workbook"; filename="{filename}"' + disposition = 'name="tableau_workbook"; filename="{}"'.format(filename) with requests_mock.mock() as m: m.get( self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", @@ -659,7 +659,7 @@ def test_revisions(self) -> None: response_xml = read_xml_asset(REVISION_XML) with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{datasource.id}/revisions", text=response_xml) + m.get("{0}/{1}/revisions".format(self.baseurl, datasource.id), text=response_xml) self.server.datasources.populate_revisions(datasource) revisions = datasource.revisions @@ -687,7 +687,7 @@ def test_delete_revision(self) -> None: datasource._id = "06b944d2-959d-4604-9305-12323c95e70e" with requests_mock.mock() as m: - m.delete(f"{self.baseurl}/{datasource.id}/revisions/3") + m.delete("{0}/{1}/revisions/3".format(self.baseurl, datasource.id)) self.server.datasources.delete_revision(datasource.id, "3") def test_download_revision(self) -> None: diff --git a/test/test_endpoint.py b/test/test_endpoint.py index ff1ef0f72..8635af978 100644 --- a/test/test_endpoint.py +++ b/test/test_endpoint.py @@ -54,7 +54,7 @@ def test_get_request_stream(self) -> None: self.assertFalse(response._content_consumed) def test_binary_log_truncated(self): - class FakeResponse: + class FakeResponse(object): headers = {"Content-Type": "application/octet-stream"} content = b"\x1337" * 1000 status_code = 200 diff --git a/test/test_favorites.py b/test/test_favorites.py index 87332d70f..6f0be3b3c 100644 --- a/test/test_favorites.py +++ b/test/test_favorites.py @@ -28,7 +28,7 @@ def setUp(self): def test_get(self) -> None: response_xml = read_xml_asset(GET_FAVORITES_XML) with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{self.user.id}", text=response_xml) + m.get("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) self.server.favorites.get(self.user) self.assertIsNotNone(self.user._favorites) self.assertEqual(len(self.user.favorites["workbooks"]), 1) @@ -54,7 +54,7 @@ def test_add_favorite_workbook(self) -> None: workbook._id = "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00" workbook.name = "Superstore" with requests_mock.mock() as m: - m.put(f"{self.baseurl}/{self.user.id}", text=response_xml) + m.put("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) self.server.favorites.add_favorite_workbook(self.user, workbook) def test_add_favorite_view(self) -> None: @@ -63,7 +63,7 @@ def test_add_favorite_view(self) -> None: view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" view._name = "ENDANGERED SAFARI" with requests_mock.mock() as m: - m.put(f"{self.baseurl}/{self.user.id}", text=response_xml) + m.put("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) self.server.favorites.add_favorite_view(self.user, view) def test_add_favorite_datasource(self) -> None: @@ -72,7 +72,7 @@ def test_add_favorite_datasource(self) -> None: datasource._id = "e76a1461-3b1d-4588-bf1b-17551a879ad9" datasource.name = "SampleDS" with requests_mock.mock() as m: - m.put(f"{self.baseurl}/{self.user.id}", text=response_xml) + m.put("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) self.server.favorites.add_favorite_datasource(self.user, datasource) def test_add_favorite_project(self) -> None: @@ -82,7 +82,7 @@ def test_add_favorite_project(self) -> None: project = TSC.ProjectItem("Tableau") project._id = "1d0304cd-3796-429f-b815-7258370b9b74" with requests_mock.mock() as m: - m.put(f"{baseurl}/{self.user.id}", text=response_xml) + m.put("{0}/{1}".format(baseurl, self.user.id), text=response_xml) self.server.favorites.add_favorite_project(self.user, project) def test_delete_favorite_workbook(self) -> None: @@ -90,7 +90,7 @@ def test_delete_favorite_workbook(self) -> None: workbook._id = "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00" workbook.name = "Superstore" with requests_mock.mock() as m: - m.delete(f"{self.baseurl}/{self.user.id}/workbooks/{workbook.id}") + m.delete("{0}/{1}/workbooks/{2}".format(self.baseurl, self.user.id, workbook.id)) self.server.favorites.delete_favorite_workbook(self.user, workbook) def test_delete_favorite_view(self) -> None: @@ -98,7 +98,7 @@ def test_delete_favorite_view(self) -> None: view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" view._name = "ENDANGERED SAFARI" with requests_mock.mock() as m: - m.delete(f"{self.baseurl}/{self.user.id}/views/{view.id}") + m.delete("{0}/{1}/views/{2}".format(self.baseurl, self.user.id, view.id)) self.server.favorites.delete_favorite_view(self.user, view) def test_delete_favorite_datasource(self) -> None: @@ -106,7 +106,7 @@ def test_delete_favorite_datasource(self) -> None: datasource._id = "e76a1461-3b1d-4588-bf1b-17551a879ad9" datasource.name = "SampleDS" with requests_mock.mock() as m: - m.delete(f"{self.baseurl}/{self.user.id}/datasources/{datasource.id}") + m.delete("{0}/{1}/datasources/{2}".format(self.baseurl, self.user.id, datasource.id)) self.server.favorites.delete_favorite_datasource(self.user, datasource) def test_delete_favorite_project(self) -> None: @@ -115,5 +115,5 @@ def test_delete_favorite_project(self) -> None: project = TSC.ProjectItem("Tableau") project._id = "1d0304cd-3796-429f-b815-7258370b9b74" with requests_mock.mock() as m: - m.delete(f"{baseurl}/{self.user.id}/projects/{project.id}") + m.delete("{0}/{1}/projects/{2}".format(baseurl, self.user.id, project.id)) self.server.favorites.delete_favorite_project(self.user, project) diff --git a/test/test_filesys_helpers.py b/test/test_filesys_helpers.py index 0f3234d5d..4c8fb0f9f 100644 --- a/test/test_filesys_helpers.py +++ b/test/test_filesys_helpers.py @@ -37,7 +37,7 @@ def test_get_file_type_identifies_a_zip_file(self): with BytesIO() as file_object: with ZipFile(file_object, "w") as zf: with BytesIO() as stream: - stream.write(b"This is a zip file") + stream.write("This is a zip file".encode()) zf.writestr("dummy_file", stream.getbuffer()) file_object.seek(0) file_type = get_file_type(file_object) diff --git a/test/test_fileuploads.py b/test/test_fileuploads.py index 9567bc3ad..50a5ef48b 100644 --- a/test/test_fileuploads.py +++ b/test/test_fileuploads.py @@ -33,7 +33,7 @@ def setUp(self): self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - self.baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/fileUploads" + self.baseurl = "{}/sites/{}/fileUploads".format(self.server.baseurl, self.server.site_id) def test_read_chunks_file_path(self): file_path = asset("SampleWB.twbx") @@ -57,7 +57,7 @@ def test_upload_chunks_file_path(self): append_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=initialize_response_xml) - m.put(f"{self.baseurl}/{upload_id}", text=append_response_xml) + m.put("{}/{}".format(self.baseurl, upload_id), text=append_response_xml) actual = self.server.fileuploads.upload(file_path) self.assertEqual(upload_id, actual) @@ -72,7 +72,7 @@ def test_upload_chunks_file_object(self): append_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=initialize_response_xml) - m.put(f"{self.baseurl}/{upload_id}", text=append_response_xml) + m.put("{}/{}".format(self.baseurl, upload_id), text=append_response_xml) actual = self.server.fileuploads.upload(file_content) self.assertEqual(upload_id, actual) diff --git a/test/test_flowruns.py b/test/test_flowruns.py index 8af2540dc..864c0d3cd 100644 --- a/test/test_flowruns.py +++ b/test/test_flowruns.py @@ -1,4 +1,3 @@ -import sys import unittest import requests_mock @@ -6,7 +5,7 @@ import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime from tableauserverclient.server.endpoint.exceptions import FlowRunFailedException -from ._utils import read_xml_asset, mocked_time, server_response_error_factory +from ._utils import read_xml_asset, mocked_time GET_XML = "flow_runs_get.xml" GET_BY_ID_XML = "flow_runs_get_by_id.xml" @@ -29,8 +28,9 @@ def test_get(self) -> None: response_xml = read_xml_asset(GET_XML) with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) - all_flow_runs = self.server.flow_runs.get() + all_flow_runs, pagination_item = self.server.flow_runs.get() + self.assertEqual(2, pagination_item.total_available) self.assertEqual("cc2e652d-4a9b-4476-8c93-b238c45db968", all_flow_runs[0].id) self.assertEqual("2021-02-11T01:42:55Z", format_datetime(all_flow_runs[0].started_at)) self.assertEqual("2021-02-11T01:57:38Z", format_datetime(all_flow_runs[0].completed_at)) @@ -75,7 +75,7 @@ def test_wait_for_job_finished(self) -> None: response_xml = read_xml_asset(GET_BY_ID_XML) flow_run_id = "cc2e652d-4a9b-4476-8c93-b238c45db968" with mocked_time(), requests_mock.mock() as m: - m.get(f"{self.baseurl}/{flow_run_id}", text=response_xml) + m.get("{0}/{1}".format(self.baseurl, flow_run_id), text=response_xml) flow_run = self.server.flow_runs.wait_for_job(flow_run_id) self.assertEqual(flow_run_id, flow_run.id) @@ -86,7 +86,7 @@ def test_wait_for_job_failed(self) -> None: response_xml = read_xml_asset(GET_BY_ID_FAILED_XML) flow_run_id = "c2b35d5a-e130-471a-aec8-7bc5435fe0e7" with mocked_time(), requests_mock.mock() as m: - m.get(f"{self.baseurl}/{flow_run_id}", text=response_xml) + m.get("{0}/{1}".format(self.baseurl, flow_run_id), text=response_xml) with self.assertRaises(FlowRunFailedException): self.server.flow_runs.wait_for_job(flow_run_id) @@ -95,17 +95,6 @@ def test_wait_for_job_timeout(self) -> None: response_xml = read_xml_asset(GET_BY_ID_INPROGRESS_XML) flow_run_id = "71afc22c-9c06-40be-8d0f-4c4166d29e6c" with mocked_time(), requests_mock.mock() as m: - m.get(f"{self.baseurl}/{flow_run_id}", text=response_xml) + m.get("{0}/{1}".format(self.baseurl, flow_run_id), text=response_xml) with self.assertRaises(TimeoutError): self.server.flow_runs.wait_for_job(flow_run_id, timeout=30) - - def test_queryset(self) -> None: - response_xml = read_xml_asset(GET_XML) - error_response = server_response_error_factory( - "400006", "Bad Request", "0xB4EAB088 : The start index '9900' is greater than or equal to the total count.)" - ) - with requests_mock.mock() as m: - m.get(f"{self.baseurl}?pageNumber=1", text=response_xml) - m.get(f"{self.baseurl}?pageNumber=2", text=error_response) - queryset = self.server.flow_runs.all() - assert len(queryset) == sys.maxsize diff --git a/test/test_flowtask.py b/test/test_flowtask.py index 2d9f7c7bd..034066e64 100644 --- a/test/test_flowtask.py +++ b/test/test_flowtask.py @@ -40,7 +40,7 @@ def test_create_flow_task(self): 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(f"{self.baseurl}", text=response_xml) + m.post("{}".format(self.baseurl), text=response_xml) create_response_content = self.server.flow_tasks.create(task).decode("utf-8") self.assertTrue("schedule_id" in create_response_content) diff --git a/test/test_group.py b/test/test_group.py index 41b5992be..fc9c75a6d 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -1,3 +1,4 @@ +# encoding=utf-8 from pathlib import Path import unittest import os diff --git a/test/test_job.py b/test/test_job.py index 20b238764..d86397086 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -51,7 +51,7 @@ def test_get_by_id(self) -> None: response_xml = read_xml_asset(GET_BY_ID_XML) job_id = "2eef4225-aa0c-41c4-8662-a76d89ed7336" with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{job_id}", text=response_xml) + m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) job = self.server.jobs.get_by_id(job_id) updated_at = datetime(2020, 5, 13, 20, 25, 18, tzinfo=utc) @@ -81,7 +81,7 @@ def test_wait_for_job_finished(self) -> None: response_xml = read_xml_asset(GET_BY_ID_XML) job_id = "2eef4225-aa0c-41c4-8662-a76d89ed7336" with mocked_time(), requests_mock.mock() as m: - m.get(f"{self.baseurl}/{job_id}", text=response_xml) + m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) job = self.server.jobs.wait_for_job(job_id) self.assertEqual(job_id, job.id) @@ -92,7 +92,7 @@ def test_wait_for_job_failed(self) -> None: response_xml = read_xml_asset(GET_BY_ID_FAILED_XML) job_id = "77d5e57a-2517-479f-9a3c-a32025f2b64d" with mocked_time(), requests_mock.mock() as m: - m.get(f"{self.baseurl}/{job_id}", text=response_xml) + m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) with self.assertRaises(JobFailedException): self.server.jobs.wait_for_job(job_id) @@ -101,7 +101,7 @@ def test_wait_for_job_timeout(self) -> None: response_xml = read_xml_asset(GET_BY_ID_INPROGRESS_XML) job_id = "77d5e57a-2517-479f-9a3c-a32025f2b64d" with mocked_time(), requests_mock.mock() as m: - m.get(f"{self.baseurl}/{job_id}", text=response_xml) + m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) with self.assertRaises(TimeoutError): self.server.jobs.wait_for_job(job_id, timeout=30) diff --git a/test/test_pager.py b/test/test_pager.py index 1836095bb..c30352809 100644 --- a/test/test_pager.py +++ b/test/test_pager.py @@ -1,7 +1,6 @@ import contextlib import os import unittest -import xml.etree.ElementTree as ET import requests_mock @@ -123,14 +122,3 @@ def test_pager_view(self) -> None: m.get(self.server.views.baseurl, text=view_xml) for view in TSC.Pager(self.server.views): assert view.name is not None - - def test_queryset_no_matches(self) -> None: - elem = ET.Element("tsResponse", xmlns="http://tableau.com/api") - ET.SubElement(elem, "pagination", totalAvailable="0") - ET.SubElement(elem, "groups") - xml = ET.tostring(elem).decode("utf-8") - with requests_mock.mock() as m: - m.get(self.server.groups.baseurl, text=xml) - all_groups = self.server.groups.all() - groups = list(all_groups) - assert len(groups) == 0 diff --git a/test/test_project.py b/test/test_project.py index 430db84b2..e05785f86 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -241,9 +241,9 @@ def test_delete_permission(self) -> None: rules = TSC.PermissionsRule(grantee=GroupItem.as_reference(single_group._id), capabilities=capabilities) - endpoint = f"{single_project._id}/permissions/groups/{single_group._id}" - m.delete(f"{self.baseurl}/{endpoint}/Read/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/Write/Allow", status_code=204) + endpoint = "{}/permissions/groups/{}".format(single_project._id, single_group._id) + m.delete("{}/{}/Read/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/Write/Allow".format(self.baseurl, endpoint), status_code=204) self.server.projects.delete_permission(item=single_project, rules=rules) def test_delete_workbook_default_permission(self) -> None: @@ -287,19 +287,19 @@ def test_delete_workbook_default_permission(self) -> None: rules = TSC.PermissionsRule(grantee=GroupItem.as_reference(single_group._id), capabilities=capabilities) - endpoint = f"{single_project._id}/default-permissions/workbooks/groups/{single_group._id}" - m.delete(f"{self.baseurl}/{endpoint}/Read/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/ExportImage/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/ExportData/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/ViewComments/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/AddComment/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/Filter/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/ViewUnderlyingData/Deny", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/ShareView/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/WebAuthoring/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/Write/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/ExportXml/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/ChangeHierarchy/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/Delete/Deny", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/ChangePermissions/Allow", status_code=204) + endpoint = "{}/default-permissions/workbooks/groups/{}".format(single_project._id, single_group._id) + m.delete("{}/{}/Read/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ExportImage/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ExportData/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ViewComments/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/AddComment/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/Filter/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ViewUnderlyingData/Deny".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ShareView/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/WebAuthoring/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/Write/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ExportXml/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ChangeHierarchy/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/Delete/Deny".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ChangePermissions/Allow".format(self.baseurl, endpoint), status_code=204) self.server.projects.delete_workbook_default_permissions(item=single_project, rule=rules) diff --git a/test/test_regression_tests.py b/test/test_regression_tests.py index 62e301591..772704f69 100644 --- a/test/test_regression_tests.py +++ b/test/test_regression_tests.py @@ -1,5 +1,9 @@ import unittest -from unittest import mock + +try: + from unittest import mock +except ImportError: + import mock # type: ignore[no-redef] import tableauserverclient.server.request_factory as factory from tableauserverclient.helpers.strings import redact_xml diff --git a/test/test_request_option.py b/test/test_request_option.py index 7405189a3..e48f8510a 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -31,7 +31,7 @@ def setUp(self) -> None: self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - self.baseurl = f"{self.server.sites.baseurl}/{self.server._site_id}" + self.baseurl = "{0}/{1}".format(self.server.sites.baseurl, self.server._site_id) def test_pagination(self) -> None: with open(PAGINATION_XML, "rb") as f: @@ -112,9 +112,9 @@ def test_filter_tags_in(self) -> None: matching_workbooks, pagination_item = self.server.workbooks.get(req_option) self.assertEqual(3, pagination_item.total_available) - self.assertEqual({"weather"}, matching_workbooks[0].tags) - self.assertEqual({"safari"}, matching_workbooks[1].tags) - self.assertEqual({"sample"}, matching_workbooks[2].tags) + self.assertEqual(set(["weather"]), matching_workbooks[0].tags) + 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 @@ -148,9 +148,9 @@ def test_filter_tags_in_shorthand(self) -> None: matching_workbooks = self.server.workbooks.filter(tags__in=["sample", "safari", "weather"]) self.assertEqual(3, matching_workbooks.total_available) - self.assertEqual({"weather"}, matching_workbooks[0].tags) - self.assertEqual({"safari"}, matching_workbooks[1].tags) - self.assertEqual({"sample"}, matching_workbooks[2].tags) + self.assertEqual(set(["weather"]), matching_workbooks[0].tags) + self.assertEqual(set(["safari"]), matching_workbooks[1].tags) + self.assertEqual(set(["sample"]), matching_workbooks[2].tags) def test_invalid_shorthand_option(self) -> None: with self.assertRaises(ValueError): @@ -358,13 +358,3 @@ def test_queryset_pagesize_filter(self) -> None: queryset = self.server.views.all().filter(page_size=page_size) assert queryset.request_options.pagesize == page_size _ = list(queryset) - - def test_language_export(self) -> None: - with requests_mock.mock() as m: - m.get(requests_mock.ANY) - url = self.baseurl + "/views/456/data" - opts = TSC.PDFRequestOptions() - opts.language = "en-US" - - resp = self.server.users.get_request(url, request_object=opts) - self.assertTrue(re.search("language=en-us", resp.request.query)) diff --git a/test/test_schedule.py b/test/test_schedule.py index b072522a4..0377295d7 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -106,7 +106,7 @@ def test_get_by_id(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" + baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -120,7 +120,7 @@ def test_get_hourly_by_id(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" + baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -135,7 +135,7 @@ def test_get_daily_by_id(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" + baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -150,7 +150,7 @@ def test_get_monthly_by_id(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" + baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -165,7 +165,7 @@ def test_get_monthly_by_id_2(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "8c5caf33-6223-4724-83c3-ccdc1e730a07" - baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" + baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -347,7 +347,7 @@ def test_update_after_get(self) -> None: def test_add_workbook(self) -> None: self.server.version = "2.8" - baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" + baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) with open(WORKBOOK_GET_BY_ID_XML, "rb") as f: workbook_response = f.read().decode("utf-8") @@ -362,7 +362,7 @@ def test_add_workbook(self) -> None: def test_add_workbook_with_warnings(self) -> None: self.server.version = "2.8" - baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" + baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) with open(WORKBOOK_GET_BY_ID_XML, "rb") as f: workbook_response = f.read().decode("utf-8") @@ -378,7 +378,7 @@ def test_add_workbook_with_warnings(self) -> None: def test_add_datasource(self) -> None: self.server.version = "2.8" - baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" + baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) with open(DATASOURCE_GET_BY_ID_XML, "rb") as f: datasource_response = f.read().decode("utf-8") @@ -393,7 +393,7 @@ def test_add_datasource(self) -> None: def test_add_flow(self) -> None: self.server.version = "3.3" - baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" + baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) with open(FLOW_GET_BY_ID_XML, "rb") as f: flow_response = f.read().decode("utf-8") diff --git a/test/test_server_info.py b/test/test_server_info.py index fa1472c9a..1cf190ecd 100644 --- a/test/test_server_info.py +++ b/test/test_server_info.py @@ -4,7 +4,6 @@ import requests_mock import tableauserverclient as TSC -from tableauserverclient.server.endpoint.exceptions import NonXMLResponseError TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -12,7 +11,6 @@ SERVER_INFO_25_XML = os.path.join(TEST_ASSET_DIR, "server_info_25.xml") SERVER_INFO_404 = os.path.join(TEST_ASSET_DIR, "server_info_404.xml") SERVER_INFO_AUTH_INFO_XML = os.path.join(TEST_ASSET_DIR, "server_info_auth_info.xml") -SERVER_INFO_WRONG_SITE = os.path.join(TEST_ASSET_DIR, "server_info_wrong_site.html") class ServerInfoTests(unittest.TestCase): @@ -65,11 +63,3 @@ def test_server_use_server_version_flag(self): m.get("http://test/api/2.4/serverInfo", text=si_response_xml) server = TSC.Server("http://test", use_server_version=True) self.assertEqual(server.version, "2.5") - - def test_server_wrong_site(self): - with open(SERVER_INFO_WRONG_SITE, "rb") as f: - response = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.server.server_info.baseurl, text=response, status_code=404) - with self.assertRaises(NonXMLResponseError): - self.server.server_info.get() diff --git a/test/test_site_model.py b/test/test_site_model.py index 60ad9c5e5..f62eb66f0 100644 --- a/test/test_site_model.py +++ b/test/test_site_model.py @@ -1,3 +1,5 @@ +# coding=utf-8 + import unittest import tableauserverclient as TSC diff --git a/test/test_tagging.py b/test/test_tagging.py index 23dffebfb..0184af415 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -1,6 +1,6 @@ from contextlib import ExitStack import re -from collections.abc import Iterable +from typing import Iterable import uuid from xml.etree import ElementTree as ET @@ -172,7 +172,7 @@ def test_update_tags(get_server, endpoint_type, item, tags) -> None: if isinstance(item, str): stack.enter_context(pytest.raises((ValueError, NotImplementedError))) elif hasattr(item, "_initial_tags"): - initial_tags = {"x", "y", "z"} + initial_tags = set(["x", "y", "z"]) item._initial_tags = initial_tags add_tags_xml = add_tag_xml_response_factory(tags - initial_tags) delete_tags_xml = add_tag_xml_response_factory(initial_tags - tags) diff --git a/test/test_task.py b/test/test_task.py index 2d724b879..53da7c160 100644 --- a/test/test_task.py +++ b/test/test_task.py @@ -119,7 +119,7 @@ def test_get_materializeviews_tasks(self): with open(GET_XML_DATAACCELERATION_TASK, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(f"{self.server.tasks.baseurl}/{TaskItem.Type.DataAcceleration}", text=response_xml) + m.get("{}/{}".format(self.server.tasks.baseurl, TaskItem.Type.DataAcceleration), text=response_xml) all_tasks, pagination_item = self.server.tasks.get(task_type=TaskItem.Type.DataAcceleration) task = all_tasks[0] @@ -145,7 +145,7 @@ def test_get_by_id(self): response_xml = f.read().decode("utf-8") task_id = "f84901ac-72ad-4f9b-a87e-7a3500402ad6" with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{task_id}", text=response_xml) + m.get("{}/{}".format(self.baseurl, task_id), text=response_xml) task = self.server.tasks.get_by_id(task_id) self.assertEqual("c7a9327e-1cda-4504-b026-ddb43b976d1d", task.target.id) @@ -159,7 +159,7 @@ def test_run_now(self): with open(GET_XML_RUN_NOW_RESPONSE, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(f"{self.baseurl}/{task_id}/runNow", text=response_xml) + m.post("{}/{}/runNow".format(self.baseurl, task_id), text=response_xml) job_response_content = self.server.tasks.run(task).decode("utf-8") self.assertTrue("7b6b59a8-ac3c-4d1d-2e9e-0b5b4ba8a7b6" in job_response_content) @@ -181,7 +181,7 @@ def test_create_extract_task(self): 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(f"{self.baseurl}", text=response_xml) + 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) diff --git a/test/test_user.py b/test/test_user.py index a46624845..1f5eba57f 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -1,5 +1,8 @@ +import io import os import unittest +from typing import List +from unittest.mock import MagicMock import requests_mock @@ -160,7 +163,7 @@ def test_populate_workbooks(self) -> None: self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", workbook_list[0].project_id) self.assertEqual("default", workbook_list[0].project_name) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", workbook_list[0].owner_id) - self.assertEqual({"Safari", "Sample"}, workbook_list[0].tags) + self.assertEqual(set(["Safari", "Sample"]), workbook_list[0].tags) def test_populate_workbooks_missing_id(self) -> None: single_user = TSC.UserItem("test", "Interactor") @@ -173,7 +176,7 @@ def test_populate_favorites(self) -> None: with open(GET_FAVORITES_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(f"{baseurl}/{single_user.id}", text=response_xml) + m.get("{0}/{1}".format(baseurl, single_user.id), text=response_xml) self.server.users.populate_favorites(single_user) self.assertIsNotNone(single_user._favorites) self.assertEqual(len(single_user.favorites["workbooks"]), 1) diff --git a/test/test_user_model.py b/test/test_user_model.py index a8a2c51cb..d0997b9ff 100644 --- a/test/test_user_model.py +++ b/test/test_user_model.py @@ -1,6 +1,7 @@ import logging import unittest from unittest.mock import * +from typing import List import io import pytest @@ -106,7 +107,7 @@ def test_validate_user_detail_standard(self): TSC.UserItem.CSVImport.create_user_from_line(test_line) # for file handling - def _mock_file_content(self, content: list[str]) -> io.TextIOWrapper: + def _mock_file_content(self, content: List[str]) -> io.TextIOWrapper: # the empty string represents EOF # the tests run through the file twice, first to validate then to fetch mock = MagicMock(io.TextIOWrapper) @@ -118,10 +119,10 @@ def _mock_file_content(self, content: list[str]) -> io.TextIOWrapper: def test_validate_import_file(self): test_data = self._mock_file_content(UserDataTest.valid_import_content) valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, UserDataTest.logger) - assert valid == 2, f"Expected two lines to be parsed, got {valid}" - assert invalid == [], f"Expected no failures, got {invalid}" + assert valid == 2, "Expected two lines to be parsed, got {}".format(valid) + assert invalid == [], "Expected no failures, got {}".format(invalid) def test_validate_usernames_file(self): test_data = self._mock_file_content(UserDataTest.usernames) valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, UserDataTest.logger) - assert valid == 5, f"Exactly 5 of the lines were valid, counted {valid + invalid}" + assert valid == 5, "Exactly 5 of the lines were valid, counted {}".format(valid + invalid) diff --git a/test/test_view.py b/test/test_view.py index a89a6d235..1c667a4c3 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -49,7 +49,7 @@ def test_get(self) -> None: self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", all_views[0].workbook_id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_views[0].owner_id) self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", all_views[0].project_id) - self.assertEqual({"tag1", "tag2"}, all_views[0].tags) + self.assertEqual(set(["tag1", "tag2"]), all_views[0].tags) self.assertIsNone(all_views[0].created_at) self.assertIsNone(all_views[0].updated_at) self.assertIsNone(all_views[0].sheet_type) @@ -77,7 +77,7 @@ def test_get_by_id(self) -> None: 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({"tag1", "tag2"}, view.tags) + 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) @@ -95,7 +95,7 @@ def test_get_by_id_usage(self) -> None: 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({"tag1", "tag2"}, view.tags) + 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) diff --git a/test/test_view_acceleration.py b/test/test_view_acceleration.py index 766831b0a..6f94f0c10 100644 --- a/test/test_view_acceleration.py +++ b/test/test_view_acceleration.py @@ -42,7 +42,7 @@ def test_get_by_id(self) -> None: 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({"Safari", "Sample"}, single_workbook.tags) + 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) diff --git a/test/test_workbook.py b/test/test_workbook.py index 1a6b3192f..950118dc0 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -83,7 +83,7 @@ def test_get(self) -> None: self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", all_workbooks[1].project_id) self.assertEqual("default", all_workbooks[1].project_name) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_workbooks[1].owner_id) - self.assertEqual({"Safari", "Sample"}, all_workbooks[1].tags) + self.assertEqual(set(["Safari", "Sample"]), all_workbooks[1].tags) def test_get_ignore_invalid_date(self) -> None: with open(GET_INVALID_DATE_XML, "rb") as f: @@ -127,7 +127,7 @@ def test_get_by_id(self) -> None: 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({"Safari", "Sample"}, single_workbook.tags) + 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) @@ -152,7 +152,7 @@ def test_get_by_id_personal(self) -> None: self.assertTrue(single_workbook.project_id) self.assertIsNone(single_workbook.project_name) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_workbook.owner_id) - self.assertEqual({"Safari", "Sample"}, single_workbook.tags) + 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) @@ -277,7 +277,7 @@ def test_download_object(self) -> None: def test_download_sanitizes_name(self) -> None: filename = "Name,With,Commas.twbx" - disposition = f'name="tableau_workbook"; filename="{filename}"' + disposition = 'name="tableau_workbook"; filename="{}"'.format(filename) with requests_mock.mock() as m: m.get( self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", @@ -817,7 +817,7 @@ def test_revisions(self) -> None: with open(REVISION_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{workbook.id}/revisions", text=response_xml) + m.get("{0}/{1}/revisions".format(self.baseurl, workbook.id), text=response_xml) self.server.workbooks.populate_revisions(workbook) revisions = workbook.revisions @@ -846,7 +846,7 @@ def test_delete_revision(self) -> None: workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" with requests_mock.mock() as m: - m.delete(f"{self.baseurl}/{workbook.id}/revisions/3") + m.delete("{0}/{1}/revisions/3".format(self.baseurl, workbook.id)) self.server.workbooks.delete_revision(workbook.id, "3") def test_download_revision(self) -> None: diff --git a/versioneer.py b/versioneer.py index cce899f58..86c240e13 100644 --- a/versioneer.py +++ b/versioneer.py @@ -276,6 +276,7 @@ """ +from __future__ import print_function try: import configparser @@ -327,7 +328,7 @@ def get_root(): me_dir = os.path.normcase(os.path.splitext(me)[0]) vsr_dir = os.path.normcase(os.path.splitext(versioneer_py)[0]) if me_dir != vsr_dir: - print(f"Warning: build in {os.path.dirname(me)} is using versioneer.py from {versioneer_py}") + print("Warning: build in %s is using versioneer.py from %s" % (os.path.dirname(me), versioneer_py)) except NameError: pass return root @@ -341,7 +342,7 @@ def get_config_from_root(root): # the top of versioneer.py for instructions on writing your setup.cfg . setup_cfg = os.path.join(root, "setup.cfg") parser = configparser.SafeConfigParser() - with open(setup_cfg) as f: + with open(setup_cfg, "r") as f: parser.readfp(f) VCS = parser.get("versioneer", "VCS") # mandatory @@ -397,7 +398,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= [c] + args, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=(subprocess.PIPE if hide_stderr else None) ) break - except OSError: + except EnvironmentError: e = sys.exc_info()[1] if e.errno == errno.ENOENT: continue @@ -407,7 +408,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= return None, None else: if verbose: - print(f"unable to find command, tried {commands}" + print("unable to find command, tried %s" % (commands,)) return None, None stdout = p.communicate()[0].strip() if sys.version_info[0] >= 3: @@ -422,7 +423,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= LONG_VERSION_PY[ "git" -] = r''' +] = ''' # This file helps to compute a version number in source trees obtained from # git-archive tarball (such as those provided by githubs download-from-tag # feature). Distribution tarballs (built by setup.py sdist) and build @@ -954,7 +955,7 @@ def git_get_keywords(versionfile_abs): # _version.py. keywords = {} try: - f = open(versionfile_abs) + f = open(versionfile_abs, "r") for line in f.readlines(): if line.strip().startswith("git_refnames ="): mo = re.search(r'=\s*"(.*)"', line) @@ -969,7 +970,7 @@ def git_get_keywords(versionfile_abs): if mo: keywords["date"] = mo.group(1) f.close() - except OSError: + except EnvironmentError: pass return keywords @@ -993,11 +994,11 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): if verbose: print("keywords are unexpanded, not using") raise NotThisMethod("unexpanded keywords, not a git-archive tarball") - refs = {r.strip() for r in refnames.strip("()").split(",")} + refs = set([r.strip() for r in refnames.strip("()").split(",")]) # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. TAG = "tag: " - tags = {r[len(TAG) :] for r in refs if r.startswith(TAG)} + tags = set([r[len(TAG) :] for r in refs if r.startswith(TAG)]) if not tags: # Either we're using git < 1.8.3, or there really are no tags. We use # a heuristic: assume all version tags have a digit. The old git %d @@ -1006,7 +1007,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # between branches and tags. By ignoring refnames without digits, we # filter out many common branch names like "release" and # "stabilization", as well as "HEAD" and "master". - tags = {r for r in refs if re.search(r"\d", r)} + tags = set([r for r in refs if re.search(r"\d", r)]) if verbose: print("discarding '%s', no digits" % ",".join(refs - tags)) if verbose: @@ -1099,7 +1100,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if verbose: fmt = "tag '%s' doesn't start with prefix '%s'" print(fmt % (full_tag, tag_prefix)) - pieces["error"] = f"tag '{full_tag}' doesn't start with prefix '{tag_prefix}'" + pieces["error"] = "tag '%s' doesn't start with prefix '%s'" % (full_tag, tag_prefix) return pieces pieces["closest-tag"] = full_tag[len(tag_prefix) :] @@ -1144,13 +1145,13 @@ def do_vcs_install(manifest_in, versionfile_source, ipy): files.append(versioneer_file) present = False try: - f = open(".gitattributes") + f = open(".gitattributes", "r") for line in f.readlines(): if line.strip().startswith(versionfile_source): if "export-subst" in line.strip().split()[1:]: present = True f.close() - except OSError: + except EnvironmentError: pass if not present: f = open(".gitattributes", "a+") @@ -1184,7 +1185,7 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): root = os.path.dirname(root) # up a level if verbose: - print(f"Tried directories {rootdirs!s} but none started with prefix {parentdir_prefix}") + print("Tried directories %s but none started with prefix %s" % (str(rootdirs), parentdir_prefix)) raise NotThisMethod("rootdir doesn't start with parentdir_prefix") @@ -1211,7 +1212,7 @@ def versions_from_file(filename): try: with open(filename) as f: contents = f.read() - except OSError: + except EnvironmentError: raise NotThisMethod("unable to read _version.py") mo = re.search(r"version_json = '''\n(.*)''' # END VERSION_JSON", contents, re.M | re.S) if not mo: @@ -1228,7 +1229,7 @@ def write_to_version_file(filename, versions): with open(filename, "w") as f: f.write(SHORT_VERSION_PY % contents) - print(f"set {filename} to '{versions['version']}'") + print("set %s to '%s'" % (filename, versions["version"])) def plus_or_dot(pieces): @@ -1451,7 +1452,7 @@ def get_versions(verbose=False): try: ver = versions_from_file(versionfile_abs) if verbose: - print(f"got version from file {versionfile_abs} {ver}") + print("got version from file %s %s" % (versionfile_abs, ver)) return ver except NotThisMethod: pass @@ -1722,7 +1723,7 @@ def do_setup(): root = get_root() try: cfg = get_config_from_root(root) - except (OSError, configparser.NoSectionError, configparser.NoOptionError) as e: + except (EnvironmentError, configparser.NoSectionError, configparser.NoOptionError) as e: if isinstance(e, (EnvironmentError, configparser.NoSectionError)): print("Adding sample versioneer config to setup.cfg", file=sys.stderr) with open(os.path.join(root, "setup.cfg"), "a") as f: @@ -1747,9 +1748,9 @@ def do_setup(): ipy = os.path.join(os.path.dirname(cfg.versionfile_source), "__init__.py") if os.path.exists(ipy): try: - with open(ipy) as f: + with open(ipy, "r") as f: old = f.read() - except OSError: + except EnvironmentError: old = "" if INIT_PY_SNIPPET not in old: print(" appending to %s" % ipy) @@ -1768,12 +1769,12 @@ def do_setup(): manifest_in = os.path.join(root, "MANIFEST.in") simple_includes = set() try: - with open(manifest_in) as f: + with open(manifest_in, "r") as f: for line in f: if line.startswith("include "): for include in line.split()[1:]: simple_includes.add(include) - except OSError: + except EnvironmentError: pass # That doesn't cover everything MANIFEST.in can do # (http://docs.python.org/2/distutils/sourcedist.html#commands), so @@ -1804,7 +1805,7 @@ def scan_setup_py(): found = set() setters = False errors = 0 - with open("setup.py") as f: + with open("setup.py", "r") as f: for line in f.readlines(): if "import versioneer" in line: found.add("import") From 34605289489851184826afd96e8d27982b765ad3 Mon Sep 17 00:00:00 2001 From: LehmD <120600174+LehmD@users.noreply.github.com> Date: Fri, 1 Nov 2024 01:31:23 +0100 Subject: [PATCH 262/296] Workbook update Description (#1516) * Allows workbook updates to change the description starting with api version 3.21 * Fixes formatting * Fixes style issues caused by using black and python version 3.13 --- tableauserverclient/server/endpoint/workbooks_endpoint.py | 2 +- tableauserverclient/server/request_factory.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 460017d1a..53bf0c1a7 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -280,7 +280,7 @@ def update( if include_view_acceleration_status: url += "?includeViewAccelerationStatus=True" - update_req = RequestFactory.Workbook.update_req(workbook_item) + update_req = RequestFactory.Workbook.update_req(workbook_item, self.parent_srv) server_response = self.put_request(url, update_req) logger.info(f"Updated workbook item (ID: {workbook_item.id})") updated_workbook = copy.copy(workbook_item) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index f7bd139d7..5849a8dae 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -960,7 +960,7 @@ def _generate_xml( _add_hiddenview_element(views_element, view_name) return ET.tostring(xml_request) - def update_req(self, workbook_item): + def update_req(self, workbook_item, parent_srv: Optional["Server"] = None): xml_request = ET.Element("tsRequest") workbook_element = ET.SubElement(xml_request, "workbook") if workbook_item.name: @@ -973,6 +973,12 @@ 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.description is not None + and parent_srv is not None + and parent_srv.check_at_least_version("3.21") + ): + workbook_element.attrib["description"] = workbook_item.description if workbook_item._views is not None: views_element = ET.SubElement(workbook_element, "views") for view in workbook_item.views: From a4278e54382cfe093266ce859d6248f859b7cc34 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Wed, 27 Nov 2024 00:19:47 -0600 Subject: [PATCH 263/296] docs: docstrings for custom_views (#1540) Also adds support for using view_id, workbook_id and owner_id to filter custom_views returned by the REST API Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- .../models/custom_view_item.py | 54 ++++- .../server/endpoint/custom_views_endpoint.py | 209 +++++++++++++++++- tableauserverclient/server/request_options.py | 3 + 3 files changed, 249 insertions(+), 17 deletions(-) diff --git a/tableauserverclient/models/custom_view_item.py b/tableauserverclient/models/custom_view_item.py index a0c0a9844..5cafe469c 100644 --- a/tableauserverclient/models/custom_view_item.py +++ b/tableauserverclient/models/custom_view_item.py @@ -5,14 +5,58 @@ from typing import Callable, Optional from collections.abc import Iterator -from .exceptions import UnpopulatedPropertyError -from .user_item import UserItem -from .view_item import ViewItem -from .workbook_item import WorkbookItem -from ..datetime_helpers import parse_datetime +from tableauserverclient.models.exceptions import UnpopulatedPropertyError +from tableauserverclient.models.user_item import UserItem +from tableauserverclient.models.view_item import ViewItem +from tableauserverclient.models.workbook_item import WorkbookItem +from tableauserverclient.datetime_helpers import parse_datetime class CustomViewItem: + """ + Represents a Custom View item on Tableau Server. + + Parameters + ---------- + id : Optional[str] + The ID of the Custom View item. + + name : Optional[str] + The name of the Custom View item. + + Attributes + ---------- + content_url : Optional[str] + The content URL of the Custom View item. + + created_at : Optional[datetime] + The date and time the Custom View item was created. + + image: bytes + The image of the Custom View item. Must be populated first. + + pdf: bytes + The PDF of the Custom View item. Must be populated first. + + csv: Iterator[bytes] + The CSV of the Custom View item. Must be populated first. + + shared : Optional[bool] + Whether the Custom View item is shared. + + updated_at : Optional[datetime] + The date and time the Custom View item was last updated. + + owner : Optional[UserItem] + The id of the owner of the Custom View item. + + workbook : Optional[WorkbookItem] + The id of the workbook the Custom View item belongs to. + + view : Optional[ViewItem] + The id of the view the Custom View item belongs to. + """ + def __init__(self, id: Optional[str] = None, name: Optional[str] = None) -> None: self._content_url: Optional[str] = None # ? self._created_at: Optional["datetime"] = None diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index b02b05d78..8d78dca7a 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -3,7 +3,7 @@ import os from contextlib import closing from pathlib import Path -from typing import Optional, Union +from typing import Optional, Union, TYPE_CHECKING from collections.abc import Iterator from tableauserverclient.config import BYTES_PER_MB, config @@ -21,6 +21,9 @@ from tableauserverclient.helpers.logging import logger +if TYPE_CHECKING: + from tableauserverclient.server.query import QuerySet + """ Get a list of custom views on a site get the details of a custom view @@ -51,19 +54,31 @@ def baseurl(self) -> str: def expurl(self) -> str: return f"{self.parent_srv._server_address}/api/exp/sites/{self.parent_srv.site_id}/customviews" - """ - If the request has no filter parameters: Administrators will see all custom views. - Other users will see only custom views that they own. - If the filter parameters include ownerId: Users will see only custom views that they own. - If the filter parameters include viewId and/or workbookId, and don't include ownerId: - Users will see those custom views that they have Write and WebAuthoring permissions for. - If site user visibility is not set to Limited, the Users will see those custom views that are "public", - meaning the value of their shared attribute is true. - If site user visibility is set to Limited, ???? - """ - @api(version="3.18") def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[CustomViewItem], PaginationItem]: + """ + Get a list of custom views on a site. + + If the request has no filter parameters: Administrators will see all custom views. + Other users will see only custom views that they own. + If the filter parameters include ownerId: Users will see only custom views that they own. + If the filter parameters include viewId and/or workbookId, and don't include ownerId: + Users will see those custom views that they have Write and WebAuthoring permissions for. + If site user visibility is not set to Limited, the Users will see those custom views that are "public", + meaning the value of their shared attribute is true. + If site user visibility is set to Limited, ???? + + Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#list_custom_views + + Parameters + ---------- + req_options : RequestOptions, optional + Filtering options for the request, by default None + + Returns + ------- + tuple[list[CustomViewItem], PaginationItem] + """ logger.info("Querying all custom views on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -73,6 +88,19 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Cust @api(version="3.18") def get_by_id(self, view_id: str) -> Optional[CustomViewItem]: + """ + Get the details of a specific custom view. + + Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#get_custom_view + + Parameters + ---------- + view_id : str + + Returns + ------- + Optional[CustomViewItem] + """ if not view_id: error = "Custom view item missing ID." raise MissingRequiredFieldError(error) @@ -83,6 +111,27 @@ def get_by_id(self, view_id: str) -> Optional[CustomViewItem]: @api(version="3.18") def populate_image(self, view_item: CustomViewItem, req_options: Optional["ImageRequestOptions"] = None) -> None: + """ + Populate the image of a custom view. + + Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#get_custom_view_image + + Parameters + ---------- + view_item : CustomViewItem + + req_options : ImageRequestOptions, optional + Options to customize the image returned, by default None + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the view_item is missing an ID + """ if not view_item.id: error = "Custom View item missing ID." raise MissingRequiredFieldError(error) @@ -101,6 +150,26 @@ def _get_view_image(self, view_item: CustomViewItem, req_options: Optional["Imag @api(version="3.23") def populate_pdf(self, custom_view_item: CustomViewItem, req_options: Optional["PDFRequestOptions"] = None) -> None: + """ + Populate the PDF of a custom view. + + Parameters + ---------- + custom_view_item : CustomViewItem + The custom view item to populate the PDF for. + + req_options : PDFRequestOptions, optional + Options to customize the PDF returned, by default None + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the custom view item is missing an ID + """ if not custom_view_item.id: error = "Custom View item missing ID." raise MissingRequiredFieldError(error) @@ -121,6 +190,26 @@ def _get_custom_view_pdf( @api(version="3.23") def populate_csv(self, custom_view_item: CustomViewItem, req_options: Optional["CSVRequestOptions"] = None) -> None: + """ + Populate the CSV of a custom view. + + Parameters + ---------- + custom_view_item : CustomViewItem + The custom view item to populate the CSV for. + + req_options : CSVRequestOptions, optional + Options to customize the CSV returned, by default None + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the custom view item is missing an ID + """ if not custom_view_item.id: error = "Custom View item missing ID." raise MissingRequiredFieldError(error) @@ -141,6 +230,21 @@ def _get_custom_view_csv( @api(version="3.18") def update(self, view_item: CustomViewItem) -> Optional[CustomViewItem]: + """ + Updates the name, owner, or shared status of a custom view. + + Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#update_custom_view + + Parameters + ---------- + view_item : CustomViewItem + The custom view item to update. + + Returns + ------- + Optional[CustomViewItem] + The updated custom view item. + """ if not view_item.id: error = "Custom view item missing ID." raise MissingRequiredFieldError(error) @@ -158,6 +262,25 @@ def update(self, view_item: CustomViewItem) -> Optional[CustomViewItem]: # Delete 1 view by id @api(version="3.19") def delete(self, view_id: str) -> None: + """ + Deletes a single custom view by ID. + + Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#delete_custom_view + + Parameters + ---------- + view_id : str + The ID of the custom view to delete. + + Returns + ------- + None + + Raises + ------ + ValueError + If the view_id is not provided. + """ if not view_id: error = "Custom View ID undefined." raise ValueError(error) @@ -167,6 +290,27 @@ def delete(self, view_id: str) -> None: @api(version="3.21") def download(self, view_item: CustomViewItem, file: PathOrFileW) -> PathOrFileW: + """ + Download the definition of a custom view as json. The file parameter can + be a file path or a file object. If a file path is provided, the file + will be written to that location. If a file object is provided, the file + will be written to that object. + + May contain sensitive information. + + Parameters + ---------- + view_item : CustomViewItem + The custom view item to download. + + file : PathOrFileW + The file path or file object to write the custom view to. + + Returns + ------- + PathOrFileW + The file path or file object that the custom view was written to. + """ url = f"{self.expurl}/{view_item.id}/content" server_response = self.get_request(url) if isinstance(file, io_types_w): @@ -180,6 +324,25 @@ def download(self, view_item: CustomViewItem, file: PathOrFileW) -> PathOrFileW: @api(version="3.21") def publish(self, view_item: CustomViewItem, file: PathOrFileR) -> Optional[CustomViewItem]: + """ + Publish a custom view to Tableau Server. The file parameter can be a + file path or a file object. If a file path is provided, the file will be + read from that location. If a file object is provided, the file will be + read from that object. + + Parameters + ---------- + view_item : CustomViewItem + The custom view item to publish. + + file : PathOrFileR + The file path or file object to read the custom view from. + + Returns + ------- + Optional[CustomViewItem] + The published custom view item. + """ url = self.expurl if isinstance(file, io_types_r): size = get_file_object_size(file) @@ -207,3 +370,25 @@ def publish(self, view_item: CustomViewItem, file: PathOrFileR) -> Optional[Cust server_response = self.post_request(url, xml_request, content_type) return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace) + + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> "QuerySet[CustomViewItem]": + """ + Queries the Tableau Server for items using the specified filters. Page + size can be specified to limit the number of items returned in a single + request. If not specified, the default page size is 100. Page size can + be an integer between 1 and 1000. + + No positional arguments are allowed. All filters must be specified as + keyword arguments. If you use the equality operator, you can specify it + through =. If you want to use a different operator, + you can specify it through __=. Field + names can either be in snake_case or camelCase. + + This endpoint supports the following fields and operators: + + view_id=... + workbook_id=... + owner_id=... + """ + + return super().filter(*invalid, page_size=page_size, **kwargs) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index d79ac7f73..2a5bb805a 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -122,6 +122,7 @@ class Field: NotificationType = "notificationType" OwnerDomain = "ownerDomain" OwnerEmail = "ownerEmail" + OwnerId = "ownerId" OwnerName = "ownerName" ParentProjectId = "parentProjectId" Priority = "priority" @@ -148,8 +149,10 @@ class Field: UpdatedAt = "updatedAt" UserCount = "userCount" UserId = "userId" + ViewId = "viewId" ViewUrlName = "viewUrlName" WorkbookDescription = "workbookDescription" + WorkbookId = "workbookId" WorkbookName = "workbookName" class Direction: From 9826cbf1b36455b1557c297557eabc7f782efe0f Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Wed, 27 Nov 2024 00:20:32 -0600 Subject: [PATCH 264/296] feat: capture site content url from sign in (#1524) Sign in attempts will return the site's content url in the response. This change parses that as well and includes it on the server object for later reference by the user. Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/server/endpoint/auth_endpoint.py | 6 ++++-- tableauserverclient/server/server.py | 11 ++++++++++- test/test_auth.py | 5 +++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 4211bb7ea..35dfa5d78 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -84,9 +84,10 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr: self._check_status(server_response, url) parsed_response = fromstring(server_response.content) site_id = parsed_response.find(".//t:site", namespaces=self.parent_srv.namespace).get("id", None) + site_url = parsed_response.find(".//t:site", namespaces=self.parent_srv.namespace).get("contentUrl", None) user_id = parsed_response.find(".//t:user", namespaces=self.parent_srv.namespace).get("id", None) auth_token = parsed_response.find("t:credentials", namespaces=self.parent_srv.namespace).get("token", None) - self.parent_srv._set_auth(site_id, user_id, auth_token) + self.parent_srv._set_auth(site_id, user_id, auth_token, site_url) logger.info(f"Signed into {self.parent_srv.server_address} as user with id {user_id}") return Auth.contextmgr(self.sign_out) @@ -155,9 +156,10 @@ def switch_site(self, site_item: "SiteItem") -> contextmgr: self._check_status(server_response, url) parsed_response = fromstring(server_response.content) site_id = parsed_response.find(".//t:site", namespaces=self.parent_srv.namespace).get("id", None) + site_url = parsed_response.find(".//t:site", namespaces=self.parent_srv.namespace).get("contentUrl", None) user_id = parsed_response.find(".//t:user", namespaces=self.parent_srv.namespace).get("id", None) auth_token = parsed_response.find("t:credentials", namespaces=self.parent_srv.namespace).get("token", None) - self.parent_srv._set_auth(site_id, user_id, auth_token) + self.parent_srv._set_auth(site_id, user_id, auth_token, site_url) logger.info(f"Signed into {self.parent_srv.server_address} as user with id {user_id}") return Auth.contextmgr(self.sign_out) diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 4eeefcaf9..02abb3fe3 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -207,12 +207,14 @@ def _clear_auth(self): self._site_id = None self._user_id = None self._auth_token = None + self._site_url = None self._session = self._session_factory() - def _set_auth(self, site_id, user_id, auth_token): + def _set_auth(self, site_id, user_id, auth_token, site_url=None): self._site_id = site_id self._user_id = user_id self._auth_token = auth_token + self._site_url = site_url def _get_legacy_version(self): # the serverInfo call was introduced in 2.4, earlier than that we have this different call @@ -282,6 +284,13 @@ def site_id(self): raise NotSignedInError(error) return self._site_id + @property + def site_url(self): + if self._site_url is None: + error = "Missing site URL. You must sign in first." + raise NotSignedInError(error) + return self._site_url + @property def user_id(self): if self._user_id is None: diff --git a/test/test_auth.py b/test/test_auth.py index 48100ad88..09e3e251d 100644 --- a/test/test_auth.py +++ b/test/test_auth.py @@ -27,6 +27,7 @@ def test_sign_in(self): self.assertEqual("eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l", self.server.auth_token) self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", self.server.site_id) + self.assertEqual("Samples", self.server.site_url) self.assertEqual("1a96d216-e9b8-497b-a82a-0b899a965e01", self.server.user_id) def test_sign_in_with_personal_access_tokens(self): @@ -41,6 +42,7 @@ def test_sign_in_with_personal_access_tokens(self): self.assertEqual("eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l", self.server.auth_token) self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", self.server.site_id) + self.assertEqual("Samples", self.server.site_url) self.assertEqual("1a96d216-e9b8-497b-a82a-0b899a965e01", self.server.user_id) def test_sign_in_impersonate(self): @@ -93,6 +95,7 @@ def test_sign_out(self): self.assertIsNone(self.server._auth_token) self.assertIsNone(self.server._site_id) + self.assertIsNone(self.server._site_url) self.assertIsNone(self.server._user_id) def test_switch_site(self): @@ -109,6 +112,7 @@ def test_switch_site(self): self.assertEqual("eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l", self.server.auth_token) self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", self.server.site_id) + self.assertEqual("Samples", self.server.site_url) self.assertEqual("1a96d216-e9b8-497b-a82a-0b899a965e01", self.server.user_id) def test_revoke_all_server_admin_tokens(self): @@ -125,4 +129,5 @@ def test_revoke_all_server_admin_tokens(self): self.assertEqual("eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l", self.server.auth_token) self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", self.server.site_id) + self.assertEqual("Samples", self.server.site_url) self.assertEqual("1a96d216-e9b8-497b-a82a-0b899a965e01", self.server.user_id) From 9d4e43ee84e6f7b17e973c45d712fae48c9caae9 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Wed, 27 Nov 2024 00:21:23 -0600 Subject: [PATCH 265/296] fix: datasource id on ConnectionItem (#1538) Closes #1536 Populates the datasource id and name on the `ConnectionItem`s as they return. Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- .../server/endpoint/datasources_endpoint.py | 7 ++++++- test/test_datasource.py | 5 +++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 6bd809c28..88c739dcf 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -102,10 +102,15 @@ def connections_fetcher(): datasource_item._set_connections(connections_fetcher) logger.info(f"Populated connections for datasource (ID: {datasource_item.id})") - def _get_datasource_connections(self, datasource_item, req_options=None): + def _get_datasource_connections( + self, datasource_item: DatasourceItem, req_options: Optional[RequestOptions] = None + ) -> list[ConnectionItem]: url = f"{self.baseurl}/{datasource_item.id}/connections" server_response = self.get_request(url, req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) + for connection in connections: + connection._datasource_id = datasource_item.id + connection._datasource_name = datasource_item.name return connections # Delete 1 datasource by id diff --git a/test/test_datasource.py b/test/test_datasource.py index 45d9ba9c9..e8a95722b 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -174,17 +174,22 @@ def test_populate_connections(self) -> None: connections: Optional[list[ConnectionItem]] = single_datasource.connections self.assertIsNotNone(connections) + assert connections is not None ds1, ds2 = connections self.assertEqual("be786ae0-d2bf-4a4b-9b34-e2de8d2d4488", ds1.id) self.assertEqual("textscan", ds1.connection_type) self.assertEqual("forty-two.net", ds1.server_address) self.assertEqual("duo", ds1.username) self.assertEqual(True, ds1.embed_password) + self.assertEqual(ds1.datasource_id, single_datasource.id) + self.assertEqual(single_datasource.name, ds1.datasource_name) self.assertEqual("970e24bc-e200-4841-a3e9-66e7d122d77e", ds2.id) self.assertEqual("sqlserver", ds2.connection_type) self.assertEqual("database.com", ds2.server_address) self.assertEqual("heero", ds2.username) self.assertEqual(False, ds2.embed_password) + self.assertEqual(ds2.datasource_id, single_datasource.id) + self.assertEqual(single_datasource.name, ds2.datasource_name) def test_update_connection(self) -> None: populate_xml, response_xml = read_xml_assets(POPULATE_CONNECTIONS_XML, UPDATE_CONNECTION_XML) From e3f1e22f1e6c37ba2a277b6cad0663c2bb776a34 Mon Sep 17 00:00:00 2001 From: renoyjohnm <168143499+renoyjohnm@users.noreply.github.com> Date: Wed, 4 Dec 2024 14:15:16 -0800 Subject: [PATCH 266/296] Adding support for thumbnail related options in workbook publish (#1542) * Adding support for thumbnail related options in workbook publish --- samples/publish_workbook.py | 38 ++++++++++++++---- tableauserverclient/models/workbook_item.py | 27 ++++++++++++- tableauserverclient/server/request_factory.py | 6 +++ test/test_workbook.py | 39 +++++++++++++++++++ 4 files changed, 102 insertions(+), 8 deletions(-) diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index d31978c0f..052eee1f5 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -36,9 +36,16 @@ def main(): help="desired logging level (set to error by default)", ) # Options specific to this sample + group = parser.add_mutually_exclusive_group(required=False) + group.add_argument("--thumbnails-user-id", "-u", help="User ID to use for thumbnails") + group.add_argument("--thumbnails-group-id", "-g", help="Group ID to use for thumbnails") + + parser.add_argument("--workbook-name", "-n", help="Name with which to publish the workbook") parser.add_argument("--file", "-f", help="local filepath of the workbook to publish") parser.add_argument("--as-job", "-a", help="Publishing asynchronously", action="store_true") parser.add_argument("--skip-connection-check", "-c", help="Skip live connection check", action="store_true") + parser.add_argument("--project", help="Project within which to publish the workbook") + parser.add_argument("--show-tabs", help="Publish workbooks with tabs displayed", action="store_true") args = parser.parse_args() @@ -50,9 +57,20 @@ def main(): tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - # Step 2: Get all the projects on server, then look for the default one. - all_projects, pagination_item = server.projects.get() - default_project = next((project for project in all_projects if project.is_default()), None) + # Step2: Retrieve the project id, if a project name was passed + if args.project is not None: + req_options = TSC.RequestOptions() + req_options.filter.add( + TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, args.project) + ) + projects = list(TSC.Pager(server.projects, req_options)) + if len(projects) > 1: + raise ValueError("The project name is not unique") + project_id = projects[0].id + else: + # Get all the projects on server, then look for the default one. + all_projects, pagination_item = server.projects.get() + project_id = next((project for project in all_projects if project.is_default()), None).id connection1 = ConnectionItem() connection1.server_address = "mssql.test.com" @@ -67,10 +85,16 @@ def main(): all_connections.append(connection1) all_connections.append(connection2) - # Step 3: If default project is found, form a new workbook item and publish. + # Step 3: Form a new workbook item and publish. overwrite_true = TSC.Server.PublishMode.Overwrite - if default_project is not None: - new_workbook = TSC.WorkbookItem(default_project.id) + if project_id is not None: + new_workbook = TSC.WorkbookItem( + project_id=project_id, + name=args.workbook_name, + show_tabs=args.show_tabs, + thumbnails_user_id=args.thumbnails_user_id, + thumbnails_group_id=args.thumbnails_group_id, + ) if args.as_job: new_job = server.workbooks.publish( new_workbook, @@ -92,7 +116,7 @@ def main(): ) print(f"Workbook published. ID: {new_workbook.id}") else: - error = "The default project could not be found." + error = "The destination project could not be found." raise LookupError(error) diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 776d041e3..32ab413a4 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -99,7 +99,14 @@ class as arguments. The workbook item specifies the project. >>> new_workbook = TSC.WorkbookItem('3a8b6148-493c-11e6-a621-6f3499394a39') """ - def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, show_tabs: bool = False) -> None: + def __init__( + self, + project_id: Optional[str] = None, + name: Optional[str] = None, + show_tabs: bool = False, + thumbnails_user_id: Optional[str] = None, + thumbnails_group_id: Optional[str] = None, + ) -> None: self._connections = None self._content_url = None self._webpage_url = None @@ -130,6 +137,8 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, } self.data_freshness_policy = None self._permissions = None + self.thumbnails_user_id = thumbnails_user_id + self.thumbnails_group_id = thumbnails_group_id return None @@ -275,6 +284,22 @@ def revisions(self) -> list[RevisionItem]: raise UnpopulatedPropertyError(error) return self._revisions() + @property + def thumbnails_user_id(self) -> Optional[str]: + return self._thumbnails_user_id + + @thumbnails_user_id.setter + def thumbnails_user_id(self, value: str): + self._thumbnails_user_id = value + + @property + def thumbnails_group_id(self) -> Optional[str]: + return self._thumbnails_group_id + + @thumbnails_group_id.setter + def thumbnails_group_id(self, value: str): + self._thumbnails_group_id = value + def _set_connections(self, connections): self._connections = connections diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 5849a8dae..f0b2d1846 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -958,6 +958,12 @@ def _generate_xml( views_element = ET.SubElement(workbook_element, "views") for view_name in workbook_item.hidden_views: _add_hiddenview_element(views_element, view_name) + + if workbook_item.thumbnails_user_id is not None: + workbook_element.attrib["thumbnailsUserId"] = workbook_item.thumbnails_user_id + elif workbook_item.thumbnails_group_id is not None: + workbook_element.attrib["thumbnailsGroupId"] = workbook_item.thumbnails_group_id + return ET.tostring(xml_request) def update_req(self, workbook_item, parent_srv: Optional["Server"] = None): diff --git a/test/test_workbook.py b/test/test_workbook.py index 1a6b3192f..0aa52f50d 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -624,6 +624,45 @@ 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)) + def test_publish_with_thumbnails_user_id(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", + thumbnails_user_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20761", + ) + + 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) + request_body = m._adapter.request_history[0]._request.body + # order of attributes in xml is unspecified + self.assertTrue(re.search(rb"thumbnailsUserId=\"ee8c6e70-43b6-11e6-af4f-f7b0d8e20761\"", request_body)) + + def test_publish_with_thumbnails_group_id(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", + thumbnails_group_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20762", + ) + + 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) + request_body = m._adapter.request_history[0]._request.body + self.assertTrue(re.search(rb"thumbnailsGroupId=\"ee8c6e70-43b6-11e6-af4f-f7b0d8e20762\"", 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: From bdfecfbf28c2adb91cea5b2539665a5dc84d60cb Mon Sep 17 00:00:00 2001 From: Jac Date: Thu, 12 Dec 2024 16:20:40 -0800 Subject: [PATCH 267/296] feat: incremental refresh for extracts (#1545) * implement incremental refresh * add sample that creates an incremental extract/runs 'refresh now' --- samples/create_extract_task.py | 19 +++++--- samples/extracts.py | 46 +++++++++++++++---- samples/publish_workbook.py | 2 +- samples/refresh.py | 33 +++++++++---- .../server/endpoint/datasources_endpoint.py | 6 +-- .../server/endpoint/workbooks_endpoint.py | 8 ++-- tableauserverclient/server/request_factory.py | 7 +++ 7 files changed, 90 insertions(+), 31 deletions(-) diff --git a/samples/create_extract_task.py b/samples/create_extract_task.py index 8408f67ee..8c02fefff 100644 --- a/samples/create_extract_task.py +++ b/samples/create_extract_task.py @@ -29,7 +29,9 @@ def main(): 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 + parser.add_argument("resource_type", choices=["workbook", "datasource"]) + parser.add_argument("resource_id") + parser.add_argument("--incremental", default=False) args = parser.parse_args() @@ -45,6 +47,7 @@ def main(): # 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) + print(monthly_interval) monthly_schedule = TSC.ScheduleItem( None, None, @@ -53,18 +56,20 @@ def main(): 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] + my_workbook: TSC.WorkbookItem = server.workbooks.get_by_id(args.resource_id) target_item = TSC.Target( my_workbook.id, # the id of the workbook or datasource "workbook", # alternatively can be "datasource" ) - extract_item = TSC.TaskItem( + refresh_type = "FullRefresh" + if args.incremental: + refresh_type = "Incremental" + + scheduled_extract_item = TSC.TaskItem( None, - "FullRefresh", + refresh_type, None, None, None, @@ -74,7 +79,7 @@ def main(): ) try: - response = server.tasks.create(extract_item) + response = server.tasks.create(scheduled_extract_item) print(response) except Exception as e: print(e) diff --git a/samples/extracts.py b/samples/extracts.py index c0dd885bc..8e7a66aac 100644 --- a/samples/extracts.py +++ b/samples/extracts.py @@ -25,8 +25,11 @@ def main(): help="desired logging level (set to error by default)", ) # Options specific to this sample - parser.add_argument("--delete") - parser.add_argument("--create") + parser.add_argument("--create", action="store_true") + parser.add_argument("--delete", action="store_true") + parser.add_argument("--refresh", action="store_true") + parser.add_argument("--workbook", required=False) + parser.add_argument("--datasource", required=False) args = parser.parse_args() # Set logging level based on user input, or error by default @@ -39,20 +42,45 @@ def main(): server.add_http_options({"verify": False}) server.use_server_version() with server.auth.sign_in(tableau_auth): - # Gets all workbook items - all_workbooks, pagination_item = server.workbooks.get() - print(f"\nThere are {pagination_item.total_available} workbooks on site: ") - print([workbook.name for workbook in all_workbooks]) - if all_workbooks: - # Pick one workbook from the list - wb = all_workbooks[3] + wb = None + ds = None + if args.workbook: + wb = server.workbooks.get_by_id(args.workbook) + if wb is None: + raise ValueError(f"Workbook not found for id {args.workbook}") + elif args.datasource: + ds = server.datasources.get_by_id(args.datasource) + if ds is None: + raise ValueError(f"Datasource not found for id {args.datasource}") + else: + # Gets all workbook items + all_workbooks, pagination_item = server.workbooks.get() + print(f"\nThere are {pagination_item.total_available} workbooks on site: ") + print([workbook.name for workbook in all_workbooks]) + + if all_workbooks: + # Pick one workbook from the list + wb = all_workbooks[3] if args.create: print("create extract on wb ", wb.name) extract_job = server.workbooks.create_extract(wb, includeAll=True) print(extract_job) + if args.refresh: + extract_job = None + if ds is not None: + print(f"refresh extract on datasource {ds.name}") + extract_job = server.datasources.refresh(ds, includeAll=True, incremental=True) + elif wb is not None: + print(f"refresh extract on workbook {wb.name}") + extract_job = server.workbooks.refresh(wb) + else: + print("no content item selected to refresh") + + print(extract_job) + if args.delete: print("delete extract on wb ", wb.name) jj = server.workbooks.delete_extract(wb) diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 052eee1f5..077ddaddd 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -55,7 +55,7 @@ def main(): # Step 1: Sign in to server. tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) - server = TSC.Server(args.server, use_server_version=True) + server = TSC.Server(args.server, use_server_version=True, http_options={"verify": False}) with server.auth.sign_in(tableau_auth): # Step2: Retrieve the project id, if a project name was passed if args.project is not None: diff --git a/samples/refresh.py b/samples/refresh.py index d3e49ed24..99242fcdb 100644 --- a/samples/refresh.py +++ b/samples/refresh.py @@ -27,6 +27,8 @@ def main(): # Options specific to this sample parser.add_argument("resource_type", choices=["workbook", "datasource"]) parser.add_argument("resource_id") + parser.add_argument("--incremental") + parser.add_argument("--synchronous") args = parser.parse_args() @@ -34,27 +36,42 @@ def main(): logging_level = getattr(logging, args.logging_level.upper()) logging.basicConfig(level=logging_level) + refresh_type = "FullRefresh" + incremental = False + if args.incremental: + refresh_type = "Incremental" + incremental = True + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) - server = TSC.Server(args.server, use_server_version=True) + server = TSC.Server(args.server, use_server_version=True, http_options={"verify": False}) with server.auth.sign_in(tableau_auth): if args.resource_type == "workbook": # Get the workbook by its Id to make sure it exists resource = server.workbooks.get_by_id(args.resource_id) + print(resource) # trigger the refresh, you'll get a job id back which can be used to poll for when the refresh is done - job = server.workbooks.refresh(args.resource_id) + job = server.workbooks.refresh(args.resource_id, incremental=incremental) else: # Get the datasource by its Id to make sure it exists resource = server.datasources.get_by_id(args.resource_id) + print(resource) + + # server.datasources.create_extract(resource) # trigger the refresh, you'll get a job id back which can be used to poll for when the refresh is done - job = server.datasources.refresh(resource) + job = server.datasources.refresh(resource, incremental=incremental) # by default runs as a sync task, - print(f"Update job posted (ID: {job.id})") - print("Waiting for job...") - # `wait_for_job` will throw if the job isn't executed successfully - job = server.jobs.wait_for_job(job) - print("Job finished succesfully") + print(f"{refresh_type} job posted (ID: {job.id})") + if args.synchronous: + # equivalent to tabcmd --synchnronous: wait for the job to complete + try: + # `wait_for_job` will throw if the job isn't executed successfully + print("Waiting for job...") + server.jobs.wait_for_job(job) + print("Job finished succesfully") + except Exception as e: + print(f"Job failed! {e}") if __name__ == "__main__": diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 88c739dcf..a7a111516 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -187,11 +187,11 @@ def update_connection( return connection @api(version="2.8") - def refresh(self, datasource_item: DatasourceItem) -> JobItem: + def refresh(self, datasource_item: DatasourceItem, incremental: bool = False) -> JobItem: id_ = getattr(datasource_item, "id", datasource_item) url = f"{self.baseurl}/{id_}/refresh" - empty_req = RequestFactory.Empty.empty_req() - server_response = self.post_request(url, empty_req) + refresh_req = RequestFactory.Task.refresh_req(incremental) + server_response = self.post_request(url, refresh_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 53bf0c1a7..4fdcf075b 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -118,7 +118,7 @@ def get_by_id(self, workbook_id: str) -> WorkbookItem: return WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="2.8") - def refresh(self, workbook_item: Union[WorkbookItem, str]) -> JobItem: + def refresh(self, workbook_item: Union[WorkbookItem, str], incremental: bool = False) -> JobItem: """ Refreshes the extract of an existing workbook. @@ -126,6 +126,8 @@ def refresh(self, workbook_item: Union[WorkbookItem, str]) -> JobItem: ---------- workbook_item : WorkbookItem | str The workbook item or workbook ID. + incremental: bool + Whether to do a full refresh or incremental refresh of the extract data Returns ------- @@ -134,8 +136,8 @@ def refresh(self, workbook_item: Union[WorkbookItem, str]) -> JobItem: """ id_ = getattr(workbook_item, "id", workbook_item) url = f"{self.baseurl}/{id_}/refresh" - empty_req = RequestFactory.Empty.empty_req() - server_response = self.post_request(url, empty_req) + refresh_req = RequestFactory.Task.refresh_req(incremental) + server_response = self.post_request(url, refresh_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index f0b2d1846..79ac6e4ca 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1117,6 +1117,13 @@ def run_req(self, xml_request: ET.Element, task_item: Any) -> None: # Send an empty tsRequest pass + @_tsrequest_wrapped + def refresh_req(self, xml_request: ET.Element, incremental: bool = False) -> bytes: + task_element = ET.SubElement(xml_request, "extractRefresh") + if incremental: + task_element.attrib["incremental"] = "true" + return ET.tostring(xml_request) + @_tsrequest_wrapped def create_extract_req(self, xml_request: ET.Element, extract_item: "TaskItem") -> bytes: extract_element = ET.SubElement(xml_request, "extractRefresh") From 9379c40f2113db5f00e90060aa331c314b374485 Mon Sep 17 00:00:00 2001 From: TrimPeachu <77048868+TrimPeachu@users.noreply.github.com> Date: Fri, 13 Dec 2024 04:28:14 +0100 Subject: [PATCH 268/296] Fixing set default permissions for virtual connections (#1535) * Fixing setting default permissions for virtual connections * Adding tests --- tableauserverclient/models/project_item.py | 2 +- ..._virtualconnection_default_permissions.xml | 19 ++++ ..._virtualconnection_default_permissions.xml | 17 +++ test/test_project.py | 107 ++++++++++++++++++ 4 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 test/assets/project_populate_virtualconnection_default_permissions.xml create mode 100644 test/assets/project_update_virtualconnection_default_permissions.xml diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 48f27c60c..b20cb5374 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -174,7 +174,7 @@ def _set_permissions(self, permissions): self._permissions = permissions def _set_default_permissions(self, permissions, content_type): - attr = f"_default_{content_type}_permissions" + attr = f"_default_{content_type}_permissions".lower() setattr( self, attr, diff --git a/test/assets/project_populate_virtualconnection_default_permissions.xml b/test/assets/project_populate_virtualconnection_default_permissions.xml new file mode 100644 index 000000000..10678f794 --- /dev/null +++ b/test/assets/project_populate_virtualconnection_default_permissions.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/project_update_virtualconnection_default_permissions.xml b/test/assets/project_update_virtualconnection_default_permissions.xml new file mode 100644 index 000000000..10b5ba6ec --- /dev/null +++ b/test/assets/project_update_virtualconnection_default_permissions.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/test_project.py b/test/test_project.py index 430db84b2..56787efac 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -16,6 +16,8 @@ POPULATE_PERMISSIONS_XML = "project_populate_permissions.xml" POPULATE_WORKBOOK_DEFAULT_PERMISSIONS_XML = "project_populate_workbook_default_permissions.xml" UPDATE_DATASOURCE_DEFAULT_PERMISSIONS_XML = "project_update_datasource_default_permissions.xml" +POPULATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML = "project_populate_virtualconnection_default_permissions.xml" +UPDATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML = "project_update_virtualconnection_default_permissions.xml" class ProjectTests(unittest.TestCase): @@ -303,3 +305,108 @@ def test_delete_workbook_default_permission(self) -> None: m.delete(f"{self.baseurl}/{endpoint}/Delete/Deny", status_code=204) m.delete(f"{self.baseurl}/{endpoint}/ChangePermissions/Allow", status_code=204) self.server.projects.delete_workbook_default_permissions(item=single_project, rule=rules) + + def test_populate_virtualconnection_default_permissions(self): + response_xml = read_xml_asset(POPULATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML) + + self.server.version = "3.23" + base_url = self.server.projects.baseurl + + with requests_mock.mock() as m: + m.get( + base_url + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/virtualConnections", + text=response_xml, + ) + project = TSC.ProjectItem("test", "1d0304cd-3796-429f-b815-7258370b9b74") + project._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + + self.server.projects.populate_virtualconnection_default_permissions(project) + permissions = project.default_virtualconnection_permissions + + rule = permissions.pop() + + self.assertEqual("c8f2773a-c83a-11e8-8c8f-33e6d787b506", rule.grantee.id) + self.assertEqual("group", rule.grantee.tag_name) + self.assertDictEqual( + rule.capabilities, + { + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Connect: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Deny, + }, + ) + + def test_update_virtualconnection_default_permissions(self): + response_xml = read_xml_asset(UPDATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML) + + self.server.version = "3.23" + base_url = self.server.projects.baseurl + + with requests_mock.mock() as m: + m.put( + base_url + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/virtualConnections", + text=response_xml, + ) + project = TSC.ProjectItem("test", "1d0304cd-3796-429f-b815-7258370b9b74") + project._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + + group = TSC.GroupItem("test-group") + group._id = "c8f2773a-c83a-11e8-8c8f-33e6d787b506" + + capabilities = { + TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Deny, + } + + rules = [TSC.PermissionsRule(GroupItem.as_reference(group.id), capabilities)] + new_rules = self.server.projects.update_virtualconnection_default_permissions(project, rules) + + rule = new_rules.pop() + + self.assertEqual(group.id, rule.grantee.id) + self.assertEqual("group", rule.grantee.tag_name) + self.assertDictEqual( + rule.capabilities, + { + TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Deny, + }, + ) + + def test_delete_virtualconnection_default_permimssions(self): + response_xml = read_xml_asset(POPULATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML) + + self.server.version = "3.23" + base_url = self.server.projects.baseurl + + with requests_mock.mock() as m: + m.get( + base_url + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/virtualConnections", + text=response_xml, + ) + + project = TSC.ProjectItem("test", "1d0304cd-3796-429f-b815-7258370b9b74") + project._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + + group = TSC.GroupItem("test-group") + group._id = "c8f2773a-c83a-11e8-8c8f-33e6d787b506" + + self.server.projects.populate_virtualconnection_default_permissions(project) + permissions = project.default_virtualconnection_permissions + + del_caps = { + TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.Connect: TSC.Permission.Mode.Allow, + } + + rule = TSC.PermissionsRule(GroupItem.as_reference(group.id), del_caps) + + endpoint = f"{project.id}/default-permissions/virtualConnections/groups/{group.id}" + m.delete(f"{base_url}/{endpoint}/ChangeHierarchy/Deny", status_code=204) + m.delete(f"{base_url}/{endpoint}/Connect/Allow", status_code=204) + + self.server.projects.delete_virtualconnection_default_permissions(project, rule) From 7a45224d1dcb045914be16686b7f6b2d742504be Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 12 Dec 2024 21:28:57 -0600 Subject: [PATCH 269/296] docs: webhook docstrings (#1530) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/webhook_item.py | 33 ++++++++ .../server/endpoint/webhooks_endpoint.py | 77 +++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/tableauserverclient/models/webhook_item.py b/tableauserverclient/models/webhook_item.py index 98d821fb4..8b551dea4 100644 --- a/tableauserverclient/models/webhook_item.py +++ b/tableauserverclient/models/webhook_item.py @@ -14,6 +14,39 @@ def _parse_event(events): class WebhookItem: + """ + The WebhookItem represents the webhook resources on Tableau Server or + Tableau Cloud. This is the information that can be sent or returned in + response to a REST API request for webhooks. + + Attributes + ---------- + id : Optional[str] + The identifier (luid) for the webhook. You need this value to query a + specific webhook with the get_by_id method or to delete a webhook with + the delete method. + + name : Optional[str] + The name of the webhook. You must specify this when you create an + instance of the WebhookItem. + + url : Optional[str] + The destination URL for the webhook. The webhook destination URL must + be https and have a valid certificate. You must specify this when you + create an instance of the WebhookItem. + + event : Optional[str] + The name of the Tableau event that triggers your webhook.This is either + api-event-name or webhook-source-api-event-name: one of these is + required to create an instance of the WebhookItem. We recommend using + the api-event-name. The event name must be one of the supported events + listed in the Trigger Events table. + https://help.tableau.com/current/developer/webhooks/en-us/docs/webhooks-events-payload.html + + owner_id : Optional[str] + The identifier (luid) of the user who owns the webhook. + """ + def __init__(self): self._id: Optional[str] = None self.name: Optional[str] = None diff --git a/tableauserverclient/server/endpoint/webhooks_endpoint.py b/tableauserverclient/server/endpoint/webhooks_endpoint.py index 06643f99d..e5c7b5897 100644 --- a/tableauserverclient/server/endpoint/webhooks_endpoint.py +++ b/tableauserverclient/server/endpoint/webhooks_endpoint.py @@ -23,6 +23,21 @@ def baseurl(self) -> str: @api(version="3.6") def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[WebhookItem], PaginationItem]: + """ + Returns a list of all webhooks on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#list_webhooks_for_site + + Parameters + ---------- + req_options : Optional[RequestOptions] + Filter and sorting options for the request. + + Returns + ------- + tuple[list[WebhookItem], PaginationItem] + A tuple of the list of webhooks and pagination item + """ logger.info("Querying all Webhooks on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -32,6 +47,21 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Webh @api(version="3.6") def get_by_id(self, webhook_id: str) -> WebhookItem: + """ + Returns information about a specified Webhook. + + Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#get_webhook + + Parameters + ---------- + webhook_id : str + The ID of the webhook to query. + + Returns + ------- + WebhookItem + An object containing information about the webhook. + """ if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) @@ -42,6 +72,20 @@ def get_by_id(self, webhook_id: str) -> WebhookItem: @api(version="3.6") def delete(self, webhook_id: str) -> None: + """ + Deletes a specified webhook. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#delete_webhook + + Parameters + ---------- + webhook_id : str + The ID of the webhook to delete. + + Returns + ------- + None + """ if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) @@ -51,6 +95,21 @@ def delete(self, webhook_id: str) -> None: @api(version="3.6") def create(self, webhook_item: WebhookItem) -> WebhookItem: + """ + Creates a new webhook on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#create_webhook + + Parameters + ---------- + webhook_item : WebhookItem + The webhook item to create. + + Returns + ------- + WebhookItem + An object containing information about the created webhook + """ url = self.baseurl create_req = RequestFactory.Webhook.create_req(webhook_item) server_response = self.post_request(url, create_req) @@ -61,6 +120,24 @@ def create(self, webhook_item: WebhookItem) -> WebhookItem: @api(version="3.6") def test(self, webhook_id: str): + """ + Tests the specified webhook. Sends an empty payload to the configured + destination URL of the webhook and returns the response from the server. + This is useful for testing, to ensure that things are being sent from + Tableau and received back as expected. + + Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#test_webhook + + Parameters + ---------- + webhook_id : str + The ID of the webhook to test. + + Returns + ------- + XML Response + + """ if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) From 9ba445b00982464e0e0ab6ed9a187127a2942557 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 12 Dec 2024 21:29:31 -0600 Subject: [PATCH 270/296] docs: task docstrings (#1527) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/task_item.py | 30 +++++++ .../server/endpoint/tasks_endpoint.py | 78 +++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index fa6f782ba..8d2492aed 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -9,6 +9,36 @@ class TaskItem: + """ + Represents a task item in Tableau Server. To create new tasks, see Schedules. + + Parameters + ---------- + id_ : str + The ID of the task. + + task_type : str + Type of task. See TaskItem.Type for possible values. + + priority : int + The priority of the task on the server. + + consecutive_failed_count : int + The number of consecutive times the task has failed. + + schedule_id : str, optional + The ID of the schedule that the task is associated with. + + schedule_item : ScheduleItem, optional + The schedule item that the task is associated with. + + last_run_at : datetime, optional + The last time the task was run. + + target : Target, optional + The target of the task. This can be a workbook or a datasource. + """ + class Type: ExtractRefresh = "extractRefresh" DataAcceleration = "dataAcceleration" diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index eb82c43bc..e1e95041d 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -31,6 +31,24 @@ def __normalize_task_type(self, task_type: str) -> str: def get( self, req_options: Optional["RequestOptions"] = None, task_type: str = TaskItem.Type.ExtractRefresh ) -> tuple[list[TaskItem], PaginationItem]: + """ + Returns information about tasks on the specified site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#list_extract_refresh_tasks + + Parameters + ---------- + req_options : RequestOptions, optional + Options for the request, such as filtering, sorting, and pagination. + + task_type : str, optional + The type of task to query. See TaskItem.Type for possible values. + + Returns + ------- + tuple[list[TaskItem], PaginationItem] + + """ if task_type == TaskItem.Type.DataAcceleration: self.parent_srv.assert_at_least_version("3.8", "Data Acceleration Tasks") @@ -45,6 +63,20 @@ def get( @api(version="2.6") def get_by_id(self, task_id: str) -> TaskItem: + """ + Returns information about the specified task. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#get_extract_refresh_task + + Parameters + ---------- + task_id : str + The ID of the task to query. + + Returns + ------- + TaskItem + """ if not task_id: error = "No Task ID provided" raise ValueError(error) @@ -59,6 +91,21 @@ def get_by_id(self, task_id: str) -> TaskItem: @api(version="3.19") def create(self, extract_item: TaskItem) -> TaskItem: + """ + Creates a custom schedule for an extract refresh on Tableau Cloud. For + Tableau Server, use the Schedules endpoint to create a schedule. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#create_cloud_extract_refresh_task + + Parameters + ---------- + extract_item : TaskItem + The extract refresh task to create. + + Returns + ------- + TaskItem + """ if not extract_item: error = "No extract refresh provided" raise ValueError(error) @@ -70,6 +117,20 @@ def create(self, extract_item: TaskItem) -> TaskItem: @api(version="2.6") def run(self, task_item: TaskItem) -> bytes: + """ + Runs the specified extract refresh task. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#run_extract_refresh_task + + Parameters + ---------- + task_item : TaskItem + The task to run. + + Returns + ------- + bytes + """ if not task_item.id: error = "Task item missing ID." raise MissingRequiredFieldError(error) @@ -86,6 +147,23 @@ def run(self, task_item: TaskItem) -> bytes: # Delete 1 task by id @api(version="3.6") def delete(self, task_id: str, task_type: str = TaskItem.Type.ExtractRefresh) -> None: + """ + Deletes the specified extract refresh task on Tableau Server or Tableau Cloud. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#delete_extract_refresh_task + + Parameters + ---------- + task_id : str + The ID of the task to delete. + + task_type : str, default TaskItem.Type.ExtractRefresh + The type of task to query. See TaskItem.Type for possible values. + + Returns + ------- + None + """ if task_type == TaskItem.Type.DataAcceleration: self.parent_srv.assert_at_least_version("3.8", "Data Acceleration Tasks") From 28952e462ac12cb62856218ccb51e9d8de6188cc Mon Sep 17 00:00:00 2001 From: Jac Date: Mon, 30 Dec 2024 13:17:39 -0800 Subject: [PATCH 271/296] feat: publish datasource as replacement (#1546) * Add "Replace" to publish type enum --- .../server/endpoint/datasources_endpoint.py | 9 ++++----- tableauserverclient/server/server.py | 1 + 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index a7a111516..1f00af570 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -260,13 +260,12 @@ def publish( else: raise TypeError("file should be a filepath or file object.") - if not mode or not hasattr(self.parent_srv.PublishMode, mode): - error = "Invalid mode defined." - raise ValueError(error) - # Construct the url with the defined mode url = f"{self.baseurl}?datasourceType={file_extension}" - if mode == self.parent_srv.PublishMode.Overwrite or mode == self.parent_srv.PublishMode.Append: + if not mode or not hasattr(self.parent_srv.PublishMode, mode): + error = f"Invalid mode defined: {mode}" + raise ValueError(error) + else: url += f"&{mode.lower()}=true" if as_job: diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 02abb3fe3..30c635e31 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -119,6 +119,7 @@ class PublishMode: Append = "Append" Overwrite = "Overwrite" CreateNew = "CreateNew" + Replace = "Replace" def __init__(self, server_address, use_server_version=False, http_options=None, session_factory=None): self._auth_token = None From e373cf4ace9a9980bfabbef4dc5d82c350fdccb3 Mon Sep 17 00:00:00 2001 From: Jac Date: Fri, 3 Jan 2025 12:03:35 -0800 Subject: [PATCH 272/296] Update versioneer (#1547) * update versioneer, exclude from linter --- pyproject.toml | 13 +- setup.cfg | 10 - setup.py | 9 - tableauserverclient/__init__.py | 6 +- tableauserverclient/{ => bin}/_version.py | 444 +++-- versioneer.py | 1845 --------------------- 6 files changed, 304 insertions(+), 2023 deletions(-) delete mode 100644 setup.cfg rename tableauserverclient/{ => bin}/_version.py (52%) delete mode 100644 versioneer.py diff --git a/pyproject.toml b/pyproject.toml index 08f90c49c..68f7589ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=68.0", "versioneer>=0.29", "wheel"] +requires = ["setuptools>=75.0", "versioneer[toml]==0.29", "wheel"] build-backend = "setuptools.build_meta" [project] @@ -16,7 +16,7 @@ dependencies = [ 'packaging>=23.1', # latest as at 7/31/23 'requests>=2.32', # latest as at 7/31/23 'urllib3>=2.2.2,<3', - 'typing_extensions>=4.0.1', + 'typing_extensions>=4.0', ] requires-python = ">=3.9" classifiers = [ @@ -38,6 +38,7 @@ test = ["black==24.8", "build", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytes [tool.black] line-length = 120 target-version = ['py39', 'py310', 'py311', 'py312', 'py313'] +force-exclude = "tableauserverclient/bin/*" [tool.mypy] check_untyped_defs = false @@ -50,7 +51,15 @@ show_error_codes = true ignore_missing_imports = true # defusedxml library has no types no_implicit_reexport = true implicit_optional = true +exclude = ['/bin/'] [tool.pytest.ini_options] testpaths = ["test"] addopts = "--junitxml=./test.junit.xml" + +[tool.versioneer] +VCS = "git" +style = "pep440-pre" +versionfile_source = "tableauserverclient/bin/_version.py" +versionfile_build = "tableauserverclient/bin/_version.py" +tag_prefix = "v" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index a551fdb6a..000000000 --- a/setup.cfg +++ /dev/null @@ -1,10 +0,0 @@ -# See the docstring in versioneer.py for instructions. Note that you must -# re-run 'versioneer.py setup' after changing this section, and commit the -# resulting files. -# versioneer does not support pyproject.toml -[versioneer] -VCS = git -style = pep440-pre -versionfile_source = tableauserverclient/_version.py -versionfile_build = tableauserverclient/_version.py -tag_prefix = v diff --git a/setup.py b/setup.py index dfd43ae8a..bdce51f2e 100644 --- a/setup.py +++ b/setup.py @@ -1,15 +1,6 @@ import versioneer from setuptools import setup -""" -once versioneer 0.25 gets released, we can move this from setup.cfg to pyproject.toml -[tool.versioneer] -VCS = "git" -style = "pep440-pre" -versionfile_source = "tableauserverclient/_version.py" -versionfile_build = "tableauserverclient/_version.py" -tag_prefix = "v" -""" setup( version=versioneer.get_version(), cmdclass=versioneer.get_cmdclass(), diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index e0a7abb64..39f8267a8 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -1,4 +1,4 @@ -from tableauserverclient._version import get_versions +from tableauserverclient.bin._version import get_versions from tableauserverclient.namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE from tableauserverclient.models import ( BackgroundJobItem, @@ -133,3 +133,7 @@ "WeeklyInterval", "WorkbookItem", ] + +from .bin import _version + +__version__ = _version.get_versions()["version"] diff --git a/tableauserverclient/_version.py b/tableauserverclient/bin/_version.py similarity index 52% rename from tableauserverclient/_version.py rename to tableauserverclient/bin/_version.py index 79dbed1d8..f23819e86 100644 --- a/tableauserverclient/_version.py +++ b/tableauserverclient/bin/_version.py @@ -1,11 +1,13 @@ + # This file helps to compute a version number in source trees obtained from # git-archive tarball (such as those provided by githubs download-from-tag # feature). Distribution tarballs (built by setup.py sdist) and build # directories (produced by setup.py build) will contain a much shorter file # that just contains the computed version number. -# This file is released into the public domain. Generated by -# versioneer-0.18 (https://github.com/warner/python-versioneer) +# This file is released into the public domain. +# Generated by versioneer-0.29 +# https://github.com/python-versioneer/python-versioneer """Git implementation of _version.py.""" @@ -14,9 +16,11 @@ import re import subprocess import sys +from typing import Any, Callable, Dict, List, Optional, Tuple +import functools -def get_keywords(): +def get_keywords() -> Dict[str, str]: """Get the keywords needed to look up the version information.""" # these strings will be replaced by git during git-archive. # setup.py/versioneer.py will grep for the variable names, so they must @@ -32,14 +36,21 @@ def get_keywords(): class VersioneerConfig: """Container for Versioneer configuration parameters.""" + VCS: str + style: str + tag_prefix: str + parentdir_prefix: str + versionfile_source: str + verbose: bool + -def get_config(): +def get_config() -> VersioneerConfig: """Create, populate and return the VersioneerConfig() object.""" # these strings are filled in when 'setup.py versioneer' creates # _version.py cfg = VersioneerConfig() cfg.VCS = "git" - cfg.style = "pep440" + cfg.style = "pep440-pre" cfg.tag_prefix = "v" cfg.parentdir_prefix = "None" cfg.versionfile_source = "tableauserverclient/_version.py" @@ -51,41 +62,50 @@ class NotThisMethod(Exception): """Exception raised if a method is not valid for the current scenario.""" -LONG_VERSION_PY = {} # type: ignore -HANDLERS = {} - +LONG_VERSION_PY: Dict[str, str] = {} +HANDLERS: Dict[str, Dict[str, Callable]] = {} -def register_vcs_handler(vcs, method): # decorator - """Decorator to mark a method as the handler for a particular VCS.""" - def decorate(f): +def register_vcs_handler(vcs: str, method: str) -> Callable: # decorator + """Create decorator to mark a method as the handler of a VCS.""" + def decorate(f: Callable) -> Callable: """Store f in HANDLERS[vcs][method].""" if vcs not in HANDLERS: HANDLERS[vcs] = {} HANDLERS[vcs][method] = f return f - return decorate -def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env=None): +def run_command( + commands: List[str], + args: List[str], + cwd: Optional[str] = None, + verbose: bool = False, + hide_stderr: bool = False, + env: Optional[Dict[str, str]] = None, +) -> Tuple[Optional[str], Optional[int]]: """Call the given command(s).""" assert isinstance(commands, list) - p = None - for c in commands: + process = None + + popen_kwargs: Dict[str, Any] = {} + if sys.platform == "win32": + # This hides the console window if pythonw.exe is used + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + popen_kwargs["startupinfo"] = startupinfo + + for command in commands: try: - dispcmd = str([c] + args) + dispcmd = str([command] + args) # remember shell=False, so use git.cmd on windows, not just git - p = subprocess.Popen( - [c] + args, - cwd=cwd, - env=env, - stdout=subprocess.PIPE, - stderr=(subprocess.PIPE if hide_stderr else None), - ) + process = subprocess.Popen([command] + args, cwd=cwd, env=env, + stdout=subprocess.PIPE, + stderr=(subprocess.PIPE if hide_stderr + else None), **popen_kwargs) break - except OSError: - e = sys.exc_info()[1] + except OSError as e: if e.errno == errno.ENOENT: continue if verbose: @@ -94,20 +114,22 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= return None, None else: if verbose: - print(f"unable to find command, tried {commands}") + print("unable to find command, tried %s" % (commands,)) return None, None - stdout = p.communicate()[0].strip() - if sys.version_info[0] >= 3: - stdout = stdout.decode() - if p.returncode != 0: + stdout = process.communicate()[0].strip().decode() + if process.returncode != 0: if verbose: print("unable to run %s (error)" % dispcmd) print("stdout was %s" % stdout) - return None, p.returncode - return stdout, p.returncode + return None, process.returncode + return stdout, process.returncode -def versions_from_parentdir(parentdir_prefix, root, verbose): +def versions_from_parentdir( + parentdir_prefix: str, + root: str, + verbose: bool, +) -> Dict[str, Any]: """Try to determine the version from the parent directory name. Source tarballs conventionally unpack into a directory that includes both @@ -116,61 +138,64 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): """ rootdirs = [] - for i in range(3): + for _ in range(3): dirname = os.path.basename(root) if dirname.startswith(parentdir_prefix): - return { - "version": dirname[len(parentdir_prefix) :], - "full-revisionid": None, - "dirty": False, - "error": None, - "date": None, - } - else: - rootdirs.append(root) - root = os.path.dirname(root) # up a level + return {"version": dirname[len(parentdir_prefix):], + "full-revisionid": None, + "dirty": False, "error": None, "date": None} + rootdirs.append(root) + root = os.path.dirname(root) # up a level if verbose: - print(f"Tried directories {str(rootdirs)} but none started with prefix {parentdir_prefix}") + print("Tried directories %s but none started with prefix %s" % + (str(rootdirs), parentdir_prefix)) raise NotThisMethod("rootdir doesn't start with parentdir_prefix") @register_vcs_handler("git", "get_keywords") -def git_get_keywords(versionfile_abs): +def git_get_keywords(versionfile_abs: str) -> Dict[str, str]: """Extract version information from the given file.""" # the code embedded in _version.py can just fetch the value of these # keywords. When used from setup.py, we don't want to import _version.py, # so we do it with a regexp instead. This function is not used from # _version.py. - keywords = {} + keywords: Dict[str, str] = {} try: - f = open(versionfile_abs) - for line in f.readlines(): - if line.strip().startswith("git_refnames ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["refnames"] = mo.group(1) - if line.strip().startswith("git_full ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["full"] = mo.group(1) - if line.strip().startswith("git_date ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["date"] = mo.group(1) - f.close() + with open(versionfile_abs, "r") as fobj: + for line in fobj: + if line.strip().startswith("git_refnames ="): + mo = re.search(r'=\s*"(.*)"', line) + if mo: + keywords["refnames"] = mo.group(1) + if line.strip().startswith("git_full ="): + mo = re.search(r'=\s*"(.*)"', line) + if mo: + keywords["full"] = mo.group(1) + if line.strip().startswith("git_date ="): + mo = re.search(r'=\s*"(.*)"', line) + if mo: + keywords["date"] = mo.group(1) except OSError: pass return keywords @register_vcs_handler("git", "keywords") -def git_versions_from_keywords(keywords, tag_prefix, verbose): +def git_versions_from_keywords( + keywords: Dict[str, str], + tag_prefix: str, + verbose: bool, +) -> Dict[str, Any]: """Get version information from git keywords.""" - if not keywords: - raise NotThisMethod("no keywords at all, weird") + if "refnames" not in keywords: + raise NotThisMethod("Short version file found") date = keywords.get("date") if date is not None: + # Use only the last line. Previous lines may contain GPG signature + # information. + date = date.splitlines()[-1] + # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant # datestamp. However we prefer "%ci" (which expands to an "ISO-8601 # -like" string, which we must then edit to make compliant), because @@ -187,7 +212,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. TAG = "tag: " - tags = {r[len(TAG) :] for r in refs if r.startswith(TAG)} + tags = {r[len(TAG):] for r in refs if r.startswith(TAG)} if not tags: # Either we're using git < 1.8.3, or there really are no tags. We use # a heuristic: assume all version tags have a digit. The old git %d @@ -196,7 +221,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # between branches and tags. By ignoring refnames without digits, we # filter out many common branch names like "release" and # "stabilization", as well as "HEAD" and "master". - tags = {r for r in refs if re.search(r"\d", r)} + tags = {r for r in refs if re.search(r'\d', r)} if verbose: print("discarding '%s', no digits" % ",".join(refs - tags)) if verbose: @@ -204,30 +229,33 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): for ref in sorted(tags): # sorting will prefer e.g. "2.0" over "2.0rc1" if ref.startswith(tag_prefix): - r = ref[len(tag_prefix) :] + r = ref[len(tag_prefix):] + # Filter out refs that exactly match prefix or that don't start + # with a number once the prefix is stripped (mostly a concern + # when prefix is '') + if not re.match(r'\d', r): + continue if verbose: print("picking %s" % r) - return { - "version": r, - "full-revisionid": keywords["full"].strip(), - "dirty": False, - "error": None, - "date": date, - } + return {"version": r, + "full-revisionid": keywords["full"].strip(), + "dirty": False, "error": None, + "date": date} # no suitable tags, so version is "0+unknown", but full hex is still there if verbose: print("no suitable tags, using unknown + full revision id") - return { - "version": "0+unknown", - "full-revisionid": keywords["full"].strip(), - "dirty": False, - "error": "no suitable tags", - "date": None, - } + return {"version": "0+unknown", + "full-revisionid": keywords["full"].strip(), + "dirty": False, "error": "no suitable tags", "date": None} @register_vcs_handler("git", "pieces_from_vcs") -def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): +def git_pieces_from_vcs( + tag_prefix: str, + root: str, + verbose: bool, + runner: Callable = run_command +) -> Dict[str, Any]: """Get version from 'git describe' in the root of the source tree. This only gets called if the git-archive 'subst' keywords were *not* @@ -238,7 +266,15 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if sys.platform == "win32": GITS = ["git.cmd", "git.exe"] - out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, hide_stderr=True) + # GIT_DIR can interfere with correct operation of Versioneer. + # It may be intended to be passed to the Versioneer-versioned project, + # but that should not change where we get our version from. + env = os.environ.copy() + env.pop("GIT_DIR", None) + runner = functools.partial(runner, env=env) + + _, rc = runner(GITS, ["rev-parse", "--git-dir"], cwd=root, + hide_stderr=not verbose) if rc != 0: if verbose: print("Directory %s not under git control" % root) @@ -246,33 +282,57 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] # if there isn't one, this yields HEX[-dirty] (no NUM) - describe_out, rc = run_command( - GITS, - [ - "describe", - "--tags", - "--dirty", - "--always", - "--long", - "--match", - "%s*" % tag_prefix, - ], - cwd=root, - ) + describe_out, rc = runner(GITS, [ + "describe", "--tags", "--dirty", "--always", "--long", + "--match", f"{tag_prefix}[[:digit:]]*" + ], cwd=root) # --long was added in git-1.5.5 if describe_out is None: raise NotThisMethod("'git describe' failed") describe_out = describe_out.strip() - full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) + full_out, rc = runner(GITS, ["rev-parse", "HEAD"], cwd=root) if full_out is None: raise NotThisMethod("'git rev-parse' failed") full_out = full_out.strip() - pieces = {} + pieces: Dict[str, Any] = {} pieces["long"] = full_out pieces["short"] = full_out[:7] # maybe improved later pieces["error"] = None + branch_name, rc = runner(GITS, ["rev-parse", "--abbrev-ref", "HEAD"], + cwd=root) + # --abbrev-ref was added in git-1.6.3 + if rc != 0 or branch_name is None: + raise NotThisMethod("'git rev-parse --abbrev-ref' returned error") + branch_name = branch_name.strip() + + if branch_name == "HEAD": + # If we aren't exactly on a branch, pick a branch which represents + # the current commit. If all else fails, we are on a branchless + # commit. + branches, rc = runner(GITS, ["branch", "--contains"], cwd=root) + # --contains was added in git-1.5.4 + if rc != 0 or branches is None: + raise NotThisMethod("'git branch --contains' returned error") + branches = branches.split("\n") + + # Remove the first line if we're running detached + if "(" in branches[0]: + branches.pop(0) + + # Strip off the leading "* " from the list of branches. + branches = [branch[2:] for branch in branches] + if "master" in branches: + branch_name = "master" + elif not branches: + branch_name = None + else: + # Pick the first branch that is returned. Good or bad. + branch_name = branches[0] + + pieces["branch"] = branch_name + # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] # TAG might have hyphens. git_describe = describe_out @@ -281,16 +341,17 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): dirty = git_describe.endswith("-dirty") pieces["dirty"] = dirty if dirty: - git_describe = git_describe[: git_describe.rindex("-dirty")] + git_describe = git_describe[:git_describe.rindex("-dirty")] # now we have TAG-NUM-gHEX or HEX if "-" in git_describe: # TAG-NUM-gHEX - mo = re.search(r"^(.+)-(\d+)-g([0-9a-f]+)$", git_describe) + mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) if not mo: - # unparseable. Maybe git-describe is misbehaving? - pieces["error"] = "unable to parse git-describe output: '%s'" % describe_out + # unparsable. Maybe git-describe is misbehaving? + pieces["error"] = ("unable to parse git-describe output: '%s'" + % describe_out) return pieces # tag @@ -299,12 +360,10 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if verbose: fmt = "tag '%s' doesn't start with prefix '%s'" print(fmt % (full_tag, tag_prefix)) - pieces["error"] = "tag '{}' doesn't start with prefix '{}'".format( - full_tag, - tag_prefix, - ) + pieces["error"] = ("tag '%s' doesn't start with prefix '%s'" + % (full_tag, tag_prefix)) return pieces - pieces["closest-tag"] = full_tag[len(tag_prefix) :] + pieces["closest-tag"] = full_tag[len(tag_prefix):] # distance: number of commits since tag pieces["distance"] = int(mo.group(2)) @@ -315,24 +374,27 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): else: # HEX: no tags pieces["closest-tag"] = None - count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], cwd=root) - pieces["distance"] = int(count_out) # total number of commits + out, rc = runner(GITS, ["rev-list", "HEAD", "--left-right"], cwd=root) + pieces["distance"] = len(out.split()) # total number of commits # commit date: see ISO-8601 comment in git_versions_from_keywords() - date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip() + date = runner(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip() + # Use only the last line. Previous lines may contain GPG signature + # information. + date = date.splitlines()[-1] pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) return pieces -def plus_or_dot(pieces): +def plus_or_dot(pieces: Dict[str, Any]) -> str: """Return a + if we don't already have one, else return a .""" if "+" in pieces.get("closest-tag", ""): return "." return "+" -def render_pep440(pieces): +def render_pep440(pieces: Dict[str, Any]) -> str: """Build up version string, with post-release "local version identifier". Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you @@ -350,29 +412,78 @@ def render_pep440(pieces): rendered += ".dirty" else: # exception #1 - rendered = "0+untagged.%d.g%s" % (pieces["distance"], pieces["short"]) + rendered = "0+untagged.%d.g%s" % (pieces["distance"], + pieces["short"]) if pieces["dirty"]: rendered += ".dirty" return rendered -def render_pep440_pre(pieces): - """TAG[.post.devDISTANCE] -- No -dirty. +def render_pep440_branch(pieces: Dict[str, Any]) -> str: + """TAG[[.dev0]+DISTANCE.gHEX[.dirty]] . + + The ".dev0" means not master branch. Note that .dev0 sorts backwards + (a feature branch will appear "older" than the master branch). Exceptions: - 1: no tags. 0.post.devDISTANCE + 1: no tags. 0[.dev0]+untagged.DISTANCE.gHEX[.dirty] """ if pieces["closest-tag"]: rendered = pieces["closest-tag"] + if pieces["distance"] or pieces["dirty"]: + if pieces["branch"] != "master": + rendered += ".dev0" + rendered += plus_or_dot(pieces) + rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) + if pieces["dirty"]: + rendered += ".dirty" + else: + # exception #1 + rendered = "0" + if pieces["branch"] != "master": + rendered += ".dev0" + rendered += "+untagged.%d.g%s" % (pieces["distance"], + pieces["short"]) + if pieces["dirty"]: + rendered += ".dirty" + return rendered + + +def pep440_split_post(ver: str) -> Tuple[str, Optional[int]]: + """Split pep440 version string at the post-release segment. + + Returns the release segments before the post-release and the + post-release version number (or -1 if no post-release segment is present). + """ + vc = str.split(ver, ".post") + return vc[0], int(vc[1] or 0) if len(vc) == 2 else None + + +def render_pep440_pre(pieces: Dict[str, Any]) -> str: + """TAG[.postN.devDISTANCE] -- No -dirty. + + Exceptions: + 1: no tags. 0.post0.devDISTANCE + """ + if pieces["closest-tag"]: if pieces["distance"]: - rendered += ".post.dev%d" % pieces["distance"] + # update the post release segment + tag_version, post_version = pep440_split_post(pieces["closest-tag"]) + rendered = tag_version + if post_version is not None: + rendered += ".post%d.dev%d" % (post_version + 1, pieces["distance"]) + else: + rendered += ".post0.dev%d" % (pieces["distance"]) + else: + # no commits, use the tag as the version + rendered = pieces["closest-tag"] else: # exception #1 - rendered = "0.post.dev%d" % pieces["distance"] + rendered = "0.post0.dev%d" % pieces["distance"] return rendered -def render_pep440_post(pieces): +def render_pep440_post(pieces: Dict[str, Any]) -> str: """TAG[.postDISTANCE[.dev0]+gHEX] . The ".dev0" means dirty. Note that .dev0 sorts backwards @@ -399,12 +510,41 @@ def render_pep440_post(pieces): return rendered -def render_pep440_old(pieces): +def render_pep440_post_branch(pieces: Dict[str, Any]) -> str: + """TAG[.postDISTANCE[.dev0]+gHEX[.dirty]] . + + The ".dev0" means not master branch. + + Exceptions: + 1: no tags. 0.postDISTANCE[.dev0]+gHEX[.dirty] + """ + if pieces["closest-tag"]: + rendered = pieces["closest-tag"] + if pieces["distance"] or pieces["dirty"]: + rendered += ".post%d" % pieces["distance"] + if pieces["branch"] != "master": + rendered += ".dev0" + rendered += plus_or_dot(pieces) + rendered += "g%s" % pieces["short"] + if pieces["dirty"]: + rendered += ".dirty" + else: + # exception #1 + rendered = "0.post%d" % pieces["distance"] + if pieces["branch"] != "master": + rendered += ".dev0" + rendered += "+g%s" % pieces["short"] + if pieces["dirty"]: + rendered += ".dirty" + return rendered + + +def render_pep440_old(pieces: Dict[str, Any]) -> str: """TAG[.postDISTANCE[.dev0]] . The ".dev0" means dirty. - Eexceptions: + Exceptions: 1: no tags. 0.postDISTANCE[.dev0] """ if pieces["closest-tag"]: @@ -421,7 +561,7 @@ def render_pep440_old(pieces): return rendered -def render_git_describe(pieces): +def render_git_describe(pieces: Dict[str, Any]) -> str: """TAG[-DISTANCE-gHEX][-dirty]. Like 'git describe --tags --dirty --always'. @@ -441,7 +581,7 @@ def render_git_describe(pieces): return rendered -def render_git_describe_long(pieces): +def render_git_describe_long(pieces: Dict[str, Any]) -> str: """TAG-DISTANCE-gHEX[-dirty]. Like 'git describe --tags --dirty --always -long'. @@ -461,26 +601,28 @@ def render_git_describe_long(pieces): return rendered -def render(pieces, style): +def render(pieces: Dict[str, Any], style: str) -> Dict[str, Any]: """Render the given version pieces into the requested style.""" if pieces["error"]: - return { - "version": "unknown", - "full-revisionid": pieces.get("long"), - "dirty": None, - "error": pieces["error"], - "date": None, - } + return {"version": "unknown", + "full-revisionid": pieces.get("long"), + "dirty": None, + "error": pieces["error"], + "date": None} if not style or style == "default": style = "pep440" # the default if style == "pep440": rendered = render_pep440(pieces) + elif style == "pep440-branch": + rendered = render_pep440_branch(pieces) elif style == "pep440-pre": rendered = render_pep440_pre(pieces) elif style == "pep440-post": rendered = render_pep440_post(pieces) + elif style == "pep440-post-branch": + rendered = render_pep440_post_branch(pieces) elif style == "pep440-old": rendered = render_pep440_old(pieces) elif style == "git-describe": @@ -490,16 +632,12 @@ def render(pieces, style): else: raise ValueError("unknown style '%s'" % style) - return { - "version": rendered, - "full-revisionid": pieces["long"], - "dirty": pieces["dirty"], - "error": None, - "date": pieces.get("date"), - } + return {"version": rendered, "full-revisionid": pieces["long"], + "dirty": pieces["dirty"], "error": None, + "date": pieces.get("date")} -def get_versions(): +def get_versions() -> Dict[str, Any]: """Get version information or return default if unable to do so.""" # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have # __file__, we can work backwards from there to the root. Some @@ -510,7 +648,8 @@ def get_versions(): verbose = cfg.verbose try: - return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, verbose) + return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, + verbose) except NotThisMethod: pass @@ -519,16 +658,13 @@ def get_versions(): # versionfile_source is the relative path from the top of the source # tree (where the .git directory might live) to this file. Invert # this to find the root from __file__. - for i in cfg.versionfile_source.split("/"): + for _ in cfg.versionfile_source.split('/'): root = os.path.dirname(root) except NameError: - return { - "version": "0+unknown", - "full-revisionid": None, - "dirty": None, - "error": "unable to find root of source tree", - "date": None, - } + return {"version": "0+unknown", "full-revisionid": None, + "dirty": None, + "error": "unable to find root of source tree", + "date": None} try: pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose) @@ -542,10 +678,6 @@ def get_versions(): except NotThisMethod: pass - return { - "version": "0+unknown", - "full-revisionid": None, - "dirty": None, - "error": "unable to compute version", - "date": None, - } + return {"version": "0+unknown", "full-revisionid": None, + "dirty": None, + "error": "unable to compute version", "date": None} diff --git a/versioneer.py b/versioneer.py deleted file mode 100644 index cce899f58..000000000 --- a/versioneer.py +++ /dev/null @@ -1,1845 +0,0 @@ -#!/usr/bin/env python -# Version: 0.18 - -"""The Versioneer - like a rocketeer, but for versions. - -The Versioneer -============== - -* like a rocketeer, but for versions! -* https://github.com/warner/python-versioneer -* Brian Warner -* License: Public Domain -* Compatible With: python2.6, 2.7, 3.2, 3.3, 3.4, 3.5, 3.6, and pypy -* [![Latest Version] -(https://pypip.in/version/versioneer/badge.svg?style=flat) -](https://pypi.python.org/pypi/versioneer/) -* [![Build Status] -(https://travis-ci.org/warner/python-versioneer.png?branch=master) -](https://travis-ci.org/warner/python-versioneer) - -This is a tool for managing a recorded version number in distutils-based -python projects. The goal is to remove the tedious and error-prone "update -the embedded version string" step from your release process. Making a new -release should be as easy as recording a new tag in your version-control -system, and maybe making new tarballs. - - -## Quick Install - -* `pip install versioneer` to somewhere to your $PATH -* add a `[versioneer]` section to your setup.cfg (see below) -* run `versioneer install` in your source tree, commit the results - -## Version Identifiers - -Source trees come from a variety of places: - -* a version-control system checkout (mostly used by developers) -* a nightly tarball, produced by build automation -* a snapshot tarball, produced by a web-based VCS browser, like github's - "tarball from tag" feature -* a release tarball, produced by "setup.py sdist", distributed through PyPI - -Within each source tree, the version identifier (either a string or a number, -this tool is format-agnostic) can come from a variety of places: - -* ask the VCS tool itself, e.g. "git describe" (for checkouts), which knows - about recent "tags" and an absolute revision-id -* the name of the directory into which the tarball was unpacked -* an expanded VCS keyword ($Id$, etc) -* a `_version.py` created by some earlier build step - -For released software, the version identifier is closely related to a VCS -tag. Some projects use tag names that include more than just the version -string (e.g. "myproject-1.2" instead of just "1.2"), in which case the tool -needs to strip the tag prefix to extract the version identifier. For -unreleased software (between tags), the version identifier should provide -enough information to help developers recreate the same tree, while also -giving them an idea of roughly how old the tree is (after version 1.2, before -version 1.3). Many VCS systems can report a description that captures this, -for example `git describe --tags --dirty --always` reports things like -"0.7-1-g574ab98-dirty" to indicate that the checkout is one revision past the -0.7 tag, has a unique revision id of "574ab98", and is "dirty" (it has -uncommitted changes. - -The version identifier is used for multiple purposes: - -* to allow the module to self-identify its version: `myproject.__version__` -* to choose a name and prefix for a 'setup.py sdist' tarball - -## Theory of Operation - -Versioneer works by adding a special `_version.py` file into your source -tree, where your `__init__.py` can import it. This `_version.py` knows how to -dynamically ask the VCS tool for version information at import time. - -`_version.py` also contains `$Revision$` markers, and the installation -process marks `_version.py` to have this marker rewritten with a tag name -during the `git archive` command. As a result, generated tarballs will -contain enough information to get the proper version. - -To allow `setup.py` to compute a version too, a `versioneer.py` is added to -the top level of your source tree, next to `setup.py` and the `setup.cfg` -that configures it. This overrides several distutils/setuptools commands to -compute the version when invoked, and changes `setup.py build` and `setup.py -sdist` to replace `_version.py` with a small static file that contains just -the generated version data. - -## Installation - -See [INSTALL.md](./INSTALL.md) for detailed installation instructions. - -## Version-String Flavors - -Code which uses Versioneer can learn about its version string at runtime by -importing `_version` from your main `__init__.py` file and running the -`get_versions()` function. From the "outside" (e.g. in `setup.py`), you can -import the top-level `versioneer.py` and run `get_versions()`. - -Both functions return a dictionary with different flavors of version -information: - -* `['version']`: A condensed version string, rendered using the selected - style. This is the most commonly used value for the project's version - string. The default "pep440" style yields strings like `0.11`, - `0.11+2.g1076c97`, or `0.11+2.g1076c97.dirty`. See the "Styles" section - below for alternative styles. - -* `['full-revisionid']`: detailed revision identifier. For Git, this is the - full SHA1 commit id, e.g. "1076c978a8d3cfc70f408fe5974aa6c092c949ac". - -* `['date']`: Date and time of the latest `HEAD` commit. For Git, it is the - commit date in ISO 8601 format. This will be None if the date is not - available. - -* `['dirty']`: a boolean, True if the tree has uncommitted changes. Note that - this is only accurate if run in a VCS checkout, otherwise it is likely to - be False or None - -* `['error']`: if the version string could not be computed, this will be set - to a string describing the problem, otherwise it will be None. It may be - useful to throw an exception in setup.py if this is set, to avoid e.g. - creating tarballs with a version string of "unknown". - -Some variants are more useful than others. Including `full-revisionid` in a -bug report should allow developers to reconstruct the exact code being tested -(or indicate the presence of local changes that should be shared with the -developers). `version` is suitable for display in an "about" box or a CLI -`--version` output: it can be easily compared against release notes and lists -of bugs fixed in various releases. - -The installer adds the following text to your `__init__.py` to place a basic -version in `YOURPROJECT.__version__`: - - from ._version import get_versions - __version__ = get_versions()['version'] - del get_versions - -## Styles - -The setup.cfg `style=` configuration controls how the VCS information is -rendered into a version string. - -The default style, "pep440", produces a PEP440-compliant string, equal to the -un-prefixed tag name for actual releases, and containing an additional "local -version" section with more detail for in-between builds. For Git, this is -TAG[+DISTANCE.gHEX[.dirty]] , using information from `git describe --tags ---dirty --always`. For example "0.11+2.g1076c97.dirty" indicates that the -tree is like the "1076c97" commit but has uncommitted changes (".dirty"), and -that this commit is two revisions ("+2") beyond the "0.11" tag. For released -software (exactly equal to a known tag), the identifier will only contain the -stripped tag, e.g. "0.11". - -Other styles are available. See [details.md](details.md) in the Versioneer -source tree for descriptions. - -## Debugging - -Versioneer tries to avoid fatal errors: if something goes wrong, it will tend -to return a version of "0+unknown". To investigate the problem, run `setup.py -version`, which will run the version-lookup code in a verbose mode, and will -display the full contents of `get_versions()` (including the `error` string, -which may help identify what went wrong). - -## Known Limitations - -Some situations are known to cause problems for Versioneer. This details the -most significant ones. More can be found on Github -[issues page](https://github.com/warner/python-versioneer/issues). - -### Subprojects - -Versioneer has limited support for source trees in which `setup.py` is not in -the root directory (e.g. `setup.py` and `.git/` are *not* siblings). The are -two common reasons why `setup.py` might not be in the root: - -* Source trees which contain multiple subprojects, such as - [Buildbot](https://github.com/buildbot/buildbot), which contains both - "master" and "slave" subprojects, each with their own `setup.py`, - `setup.cfg`, and `tox.ini`. Projects like these produce multiple PyPI - distributions (and upload multiple independently-installable tarballs). -* Source trees whose main purpose is to contain a C library, but which also - provide bindings to Python (and perhaps other langauges) in subdirectories. - -Versioneer will look for `.git` in parent directories, and most operations -should get the right version string. However `pip` and `setuptools` have bugs -and implementation details which frequently cause `pip install .` from a -subproject directory to fail to find a correct version string (so it usually -defaults to `0+unknown`). - -`pip install --editable .` should work correctly. `setup.py install` might -work too. - -Pip-8.1.1 is known to have this problem, but hopefully it will get fixed in -some later version. - -[Bug #38](https://github.com/warner/python-versioneer/issues/38) is tracking -this issue. The discussion in -[PR #61](https://github.com/warner/python-versioneer/pull/61) describes the -issue from the Versioneer side in more detail. -[pip PR#3176](https://github.com/pypa/pip/pull/3176) and -[pip PR#3615](https://github.com/pypa/pip/pull/3615) contain work to improve -pip to let Versioneer work correctly. - -Versioneer-0.16 and earlier only looked for a `.git` directory next to the -`setup.cfg`, so subprojects were completely unsupported with those releases. - -### Editable installs with setuptools <= 18.5 - -`setup.py develop` and `pip install --editable .` allow you to install a -project into a virtualenv once, then continue editing the source code (and -test) without re-installing after every change. - -"Entry-point scripts" (`setup(entry_points={"console_scripts": ..})`) are a -convenient way to specify executable scripts that should be installed along -with the python package. - -These both work as expected when using modern setuptools. When using -setuptools-18.5 or earlier, however, certain operations will cause -`pkg_resources.DistributionNotFound` errors when running the entrypoint -script, which must be resolved by re-installing the package. This happens -when the install happens with one version, then the egg_info data is -regenerated while a different version is checked out. Many setup.py commands -cause egg_info to be rebuilt (including `sdist`, `wheel`, and installing into -a different virtualenv), so this can be surprising. - -[Bug #83](https://github.com/warner/python-versioneer/issues/83) describes -this one, but upgrading to a newer version of setuptools should probably -resolve it. - -### Unicode version strings - -While Versioneer works (and is continually tested) with both Python 2 and -Python 3, it is not entirely consistent with bytes-vs-unicode distinctions. -Newer releases probably generate unicode version strings on py2. It's not -clear that this is wrong, but it may be surprising for applications when then -write these strings to a network connection or include them in bytes-oriented -APIs like cryptographic checksums. - -[Bug #71](https://github.com/warner/python-versioneer/issues/71) investigates -this question. - - -## Updating Versioneer - -To upgrade your project to a new release of Versioneer, do the following: - -* install the new Versioneer (`pip install -U versioneer` or equivalent) -* edit `setup.cfg`, if necessary, to include any new configuration settings - indicated by the release notes. See [UPGRADING](./UPGRADING.md) for details. -* re-run `versioneer install` in your source tree, to replace - `SRC/_version.py` -* commit any changed files - -## Future Directions - -This tool is designed to make it easily extended to other version-control -systems: all VCS-specific components are in separate directories like -src/git/ . The top-level `versioneer.py` script is assembled from these -components by running make-versioneer.py . In the future, make-versioneer.py -will take a VCS name as an argument, and will construct a version of -`versioneer.py` that is specific to the given VCS. It might also take the -configuration arguments that are currently provided manually during -installation by editing setup.py . Alternatively, it might go the other -direction and include code from all supported VCS systems, reducing the -number of intermediate scripts. - - -## License - -To make Versioneer easier to embed, all its code is dedicated to the public -domain. The `_version.py` that it creates is also in the public domain. -Specifically, both are released under the Creative Commons "Public Domain -Dedication" license (CC0-1.0), as described in -https://creativecommons.org/publicdomain/zero/1.0/ . - -""" - - -try: - import configparser -except ImportError: - import ConfigParser as configparser -import errno -import json -import os -import re -import subprocess -import sys - - -class VersioneerConfig: - """Container for Versioneer configuration parameters.""" - - -def get_root(): - """Get the project root directory. - - We require that all commands are run from the project root, i.e. the - directory that contains setup.py, setup.cfg, and versioneer.py . - """ - root = os.path.realpath(os.path.abspath(os.getcwd())) - setup_py = os.path.join(root, "setup.py") - versioneer_py = os.path.join(root, "versioneer.py") - if not (os.path.exists(setup_py) or os.path.exists(versioneer_py)): - # allow 'python path/to/setup.py COMMAND' - root = os.path.dirname(os.path.realpath(os.path.abspath(sys.argv[0]))) - setup_py = os.path.join(root, "setup.py") - versioneer_py = os.path.join(root, "versioneer.py") - if not (os.path.exists(setup_py) or os.path.exists(versioneer_py)): - err = ( - "Versioneer was unable to run the project root directory. " - "Versioneer requires setup.py to be executed from " - "its immediate directory (like 'python setup.py COMMAND'), " - "or in a way that lets it use sys.argv[0] to find the root " - "(like 'python path/to/setup.py COMMAND')." - ) - raise VersioneerBadRootError(err) - try: - # Certain runtime workflows (setup.py install/develop in a setuptools - # tree) execute all dependencies in a single python process, so - # "versioneer" may be imported multiple times, and python's shared - # module-import table will cache the first one. So we can't use - # os.path.dirname(__file__), as that will find whichever - # versioneer.py was first imported, even in later projects. - me = os.path.realpath(os.path.abspath(__file__)) - me_dir = os.path.normcase(os.path.splitext(me)[0]) - vsr_dir = os.path.normcase(os.path.splitext(versioneer_py)[0]) - if me_dir != vsr_dir: - print(f"Warning: build in {os.path.dirname(me)} is using versioneer.py from {versioneer_py}") - except NameError: - pass - return root - - -def get_config_from_root(root): - """Read the project setup.cfg file to determine Versioneer config.""" - # This might raise EnvironmentError (if setup.cfg is missing), or - # configparser.NoSectionError (if it lacks a [versioneer] section), or - # configparser.NoOptionError (if it lacks "VCS="). See the docstring at - # the top of versioneer.py for instructions on writing your setup.cfg . - setup_cfg = os.path.join(root, "setup.cfg") - parser = configparser.SafeConfigParser() - with open(setup_cfg) as f: - parser.readfp(f) - VCS = parser.get("versioneer", "VCS") # mandatory - - def get(parser, name): - if parser.has_option("versioneer", name): - return parser.get("versioneer", name) - return None - - cfg = VersioneerConfig() - cfg.VCS = VCS - cfg.style = get(parser, "style") or "" - cfg.versionfile_source = get(parser, "versionfile_source") - cfg.versionfile_build = get(parser, "versionfile_build") - cfg.tag_prefix = get(parser, "tag_prefix") - if cfg.tag_prefix in ("''", '""'): - cfg.tag_prefix = "" - cfg.parentdir_prefix = get(parser, "parentdir_prefix") - cfg.verbose = get(parser, "verbose") - return cfg - - -class NotThisMethod(Exception): - """Exception raised if a method is not valid for the current scenario.""" - - -# these dictionaries contain VCS-specific tools -LONG_VERSION_PY = {} -HANDLERS = {} - - -def register_vcs_handler(vcs, method): # decorator - """Decorator to mark a method as the handler for a particular VCS.""" - - def decorate(f): - """Store f in HANDLERS[vcs][method].""" - if vcs not in HANDLERS: - HANDLERS[vcs] = {} - HANDLERS[vcs][method] = f - return f - - return decorate - - -def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env=None): - """Call the given command(s).""" - assert isinstance(commands, list) - p = None - for c in commands: - try: - dispcmd = str([c] + args) - # remember shell=False, so use git.cmd on windows, not just git - p = subprocess.Popen( - [c] + args, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=(subprocess.PIPE if hide_stderr else None) - ) - break - except OSError: - e = sys.exc_info()[1] - if e.errno == errno.ENOENT: - continue - if verbose: - print("unable to run %s" % dispcmd) - print(e) - return None, None - else: - if verbose: - print(f"unable to find command, tried {commands}" - return None, None - stdout = p.communicate()[0].strip() - if sys.version_info[0] >= 3: - stdout = stdout.decode() - if p.returncode != 0: - if verbose: - print("unable to run %s (error)" % dispcmd) - print("stdout was %s" % stdout) - return None, p.returncode - return stdout, p.returncode - - -LONG_VERSION_PY[ - "git" -] = r''' -# This file helps to compute a version number in source trees obtained from -# git-archive tarball (such as those provided by githubs download-from-tag -# feature). Distribution tarballs (built by setup.py sdist) and build -# directories (produced by setup.py build) will contain a much shorter file -# that just contains the computed version number. - -# This file is released into the public domain. Generated by -# versioneer-0.18 (https://github.com/warner/python-versioneer) - -"""Git implementation of _version.py.""" - -import errno -import os -import re -import subprocess -import sys - - -def get_keywords(): - """Get the keywords needed to look up the version information.""" - # these strings will be replaced by git during git-archive. - # setup.py/versioneer.py will grep for the variable names, so they must - # each be defined on a line of their own. _version.py will just call - # get_keywords(). - git_refnames = "%(DOLLAR)sFormat:%%d%(DOLLAR)s" - git_full = "%(DOLLAR)sFormat:%%H%(DOLLAR)s" - git_date = "%(DOLLAR)sFormat:%%ci%(DOLLAR)s" - keywords = {"refnames": git_refnames, "full": git_full, "date": git_date} - return keywords - - -class VersioneerConfig: - """Container for Versioneer configuration parameters.""" - - -def get_config(): - """Create, populate and return the VersioneerConfig() object.""" - # these strings are filled in when 'setup.py versioneer' creates - # _version.py - cfg = VersioneerConfig() - cfg.VCS = "git" - cfg.style = "%(STYLE)s" - cfg.tag_prefix = "%(TAG_PREFIX)s" - cfg.parentdir_prefix = "%(PARENTDIR_PREFIX)s" - cfg.versionfile_source = "%(VERSIONFILE_SOURCE)s" - cfg.verbose = False - return cfg - - -class NotThisMethod(Exception): - """Exception raised if a method is not valid for the current scenario.""" - - -LONG_VERSION_PY = {} -HANDLERS = {} - - -def register_vcs_handler(vcs, method): # decorator - """Decorator to mark a method as the handler for a particular VCS.""" - def decorate(f): - """Store f in HANDLERS[vcs][method].""" - if vcs not in HANDLERS: - HANDLERS[vcs] = {} - HANDLERS[vcs][method] = f - return f - return decorate - - -def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, - env=None): - """Call the given command(s).""" - assert isinstance(commands, list) - p = None - for c in commands: - try: - dispcmd = str([c] + args) - # remember shell=False, so use git.cmd on windows, not just git - p = subprocess.Popen([c] + args, cwd=cwd, env=env, - stdout=subprocess.PIPE, - stderr=(subprocess.PIPE if hide_stderr - else None)) - break - except EnvironmentError: - e = sys.exc_info()[1] - if e.errno == errno.ENOENT: - continue - if verbose: - print("unable to run %%s" %% dispcmd) - print(e) - return None, None - else: - if verbose: - print("unable to find command, tried %%s" %% (commands,)) - return None, None - stdout = p.communicate()[0].strip() - if sys.version_info[0] >= 3: - stdout = stdout.decode() - if p.returncode != 0: - if verbose: - print("unable to run %%s (error)" %% dispcmd) - print("stdout was %%s" %% stdout) - return None, p.returncode - return stdout, p.returncode - - -def versions_from_parentdir(parentdir_prefix, root, verbose): - """Try to determine the version from the parent directory name. - - Source tarballs conventionally unpack into a directory that includes both - the project name and a version string. We will also support searching up - two directory levels for an appropriately named parent directory - """ - rootdirs = [] - - for i in range(3): - dirname = os.path.basename(root) - if dirname.startswith(parentdir_prefix): - return {"version": dirname[len(parentdir_prefix):], - "full-revisionid": None, - "dirty": False, "error": None, "date": None} - else: - rootdirs.append(root) - root = os.path.dirname(root) # up a level - - if verbose: - print("Tried directories %%s but none started with prefix %%s" %% - (str(rootdirs), parentdir_prefix)) - raise NotThisMethod("rootdir doesn't start with parentdir_prefix") - - -@register_vcs_handler("git", "get_keywords") -def git_get_keywords(versionfile_abs): - """Extract version information from the given file.""" - # the code embedded in _version.py can just fetch the value of these - # keywords. When used from setup.py, we don't want to import _version.py, - # so we do it with a regexp instead. This function is not used from - # _version.py. - keywords = {} - try: - f = open(versionfile_abs, "r") - for line in f.readlines(): - if line.strip().startswith("git_refnames ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["refnames"] = mo.group(1) - if line.strip().startswith("git_full ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["full"] = mo.group(1) - if line.strip().startswith("git_date ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["date"] = mo.group(1) - f.close() - except EnvironmentError: - pass - return keywords - - -@register_vcs_handler("git", "keywords") -def git_versions_from_keywords(keywords, tag_prefix, verbose): - """Get version information from git keywords.""" - if not keywords: - raise NotThisMethod("no keywords at all, weird") - date = keywords.get("date") - if date is not None: - # git-2.2.0 added "%%cI", which expands to an ISO-8601 -compliant - # datestamp. However we prefer "%%ci" (which expands to an "ISO-8601 - # -like" string, which we must then edit to make compliant), because - # it's been around since git-1.5.3, and it's too difficult to - # discover which version we're using, or to work around using an - # older one. - date = date.strip().replace(" ", "T", 1).replace(" ", "", 1) - refnames = keywords["refnames"].strip() - if refnames.startswith("$Format"): - if verbose: - print("keywords are unexpanded, not using") - raise NotThisMethod("unexpanded keywords, not a git-archive tarball") - refs = set([r.strip() for r in refnames.strip("()").split(",")]) - # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of - # just "foo-1.0". If we see a "tag: " prefix, prefer those. - TAG = "tag: " - tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) - if not tags: - # Either we're using git < 1.8.3, or there really are no tags. We use - # a heuristic: assume all version tags have a digit. The old git %%d - # expansion behaves like git log --decorate=short and strips out the - # refs/heads/ and refs/tags/ prefixes that would let us distinguish - # between branches and tags. By ignoring refnames without digits, we - # filter out many common branch names like "release" and - # "stabilization", as well as "HEAD" and "master". - tags = set([r for r in refs if re.search(r'\d', r)]) - if verbose: - print("discarding '%%s', no digits" %% ",".join(refs - tags)) - if verbose: - print("likely tags: %%s" %% ",".join(sorted(tags))) - for ref in sorted(tags): - # sorting will prefer e.g. "2.0" over "2.0rc1" - if ref.startswith(tag_prefix): - r = ref[len(tag_prefix):] - if verbose: - print("picking %%s" %% r) - return {"version": r, - "full-revisionid": keywords["full"].strip(), - "dirty": False, "error": None, - "date": date} - # no suitable tags, so version is "0+unknown", but full hex is still there - if verbose: - print("no suitable tags, using unknown + full revision id") - return {"version": "0+unknown", - "full-revisionid": keywords["full"].strip(), - "dirty": False, "error": "no suitable tags", "date": None} - - -@register_vcs_handler("git", "pieces_from_vcs") -def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): - """Get version from 'git describe' in the root of the source tree. - - This only gets called if the git-archive 'subst' keywords were *not* - expanded, and _version.py hasn't already been rewritten with a short - version string, meaning we're inside a checked out source tree. - """ - GITS = ["git"] - if sys.platform == "win32": - GITS = ["git.cmd", "git.exe"] - - out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, - hide_stderr=True) - if rc != 0: - if verbose: - print("Directory %%s not under git control" %% root) - raise NotThisMethod("'git rev-parse --git-dir' returned error") - - # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] - # if there isn't one, this yields HEX[-dirty] (no NUM) - describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty", - "--always", "--long", - "--match", "%%s*" %% tag_prefix], - cwd=root) - # --long was added in git-1.5.5 - if describe_out is None: - raise NotThisMethod("'git describe' failed") - describe_out = describe_out.strip() - full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) - if full_out is None: - raise NotThisMethod("'git rev-parse' failed") - full_out = full_out.strip() - - pieces = {} - pieces["long"] = full_out - pieces["short"] = full_out[:7] # maybe improved later - pieces["error"] = None - - # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] - # TAG might have hyphens. - git_describe = describe_out - - # look for -dirty suffix - dirty = git_describe.endswith("-dirty") - pieces["dirty"] = dirty - if dirty: - git_describe = git_describe[:git_describe.rindex("-dirty")] - - # now we have TAG-NUM-gHEX or HEX - - if "-" in git_describe: - # TAG-NUM-gHEX - mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) - if not mo: - # unparseable. Maybe git-describe is misbehaving? - pieces["error"] = ("unable to parse git-describe output: '%%s'" - %% describe_out) - return pieces - - # tag - full_tag = mo.group(1) - if not full_tag.startswith(tag_prefix): - if verbose: - fmt = "tag '%%s' doesn't start with prefix '%%s'" - print(fmt %% (full_tag, tag_prefix)) - pieces["error"] = ("tag '%%s' doesn't start with prefix '%%s'" - %% (full_tag, tag_prefix)) - return pieces - pieces["closest-tag"] = full_tag[len(tag_prefix):] - - # distance: number of commits since tag - pieces["distance"] = int(mo.group(2)) - - # commit: short hex revision ID - pieces["short"] = mo.group(3) - - else: - # HEX: no tags - pieces["closest-tag"] = None - count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], - cwd=root) - pieces["distance"] = int(count_out) # total number of commits - - # commit date: see ISO-8601 comment in git_versions_from_keywords() - date = run_command(GITS, ["show", "-s", "--format=%%ci", "HEAD"], - cwd=root)[0].strip() - pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) - - return pieces - - -def plus_or_dot(pieces): - """Return a + if we don't already have one, else return a .""" - if "+" in pieces.get("closest-tag", ""): - return "." - return "+" - - -def render_pep440(pieces): - """Build up version string, with post-release "local version identifier". - - Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you - get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty - - Exceptions: - 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - rendered += plus_or_dot(pieces) - rendered += "%%d.g%%s" %% (pieces["distance"], pieces["short"]) - if pieces["dirty"]: - rendered += ".dirty" - else: - # exception #1 - rendered = "0+untagged.%%d.g%%s" %% (pieces["distance"], - pieces["short"]) - if pieces["dirty"]: - rendered += ".dirty" - return rendered - - -def render_pep440_pre(pieces): - """TAG[.post.devDISTANCE] -- No -dirty. - - Exceptions: - 1: no tags. 0.post.devDISTANCE - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"]: - rendered += ".post.dev%%d" %% pieces["distance"] - else: - # exception #1 - rendered = "0.post.dev%%d" %% pieces["distance"] - return rendered - - -def render_pep440_post(pieces): - """TAG[.postDISTANCE[.dev0]+gHEX] . - - The ".dev0" means dirty. Note that .dev0 sorts backwards - (a dirty tree will appear "older" than the corresponding clean one), - but you shouldn't be releasing software with -dirty anyways. - - Exceptions: - 1: no tags. 0.postDISTANCE[.dev0] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - rendered += ".post%%d" %% pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - rendered += plus_or_dot(pieces) - rendered += "g%%s" %% pieces["short"] - else: - # exception #1 - rendered = "0.post%%d" %% pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - rendered += "+g%%s" %% pieces["short"] - return rendered - - -def render_pep440_old(pieces): - """TAG[.postDISTANCE[.dev0]] . - - The ".dev0" means dirty. - - Eexceptions: - 1: no tags. 0.postDISTANCE[.dev0] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - rendered += ".post%%d" %% pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - else: - # exception #1 - rendered = "0.post%%d" %% pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - return rendered - - -def render_git_describe(pieces): - """TAG[-DISTANCE-gHEX][-dirty]. - - Like 'git describe --tags --dirty --always'. - - Exceptions: - 1: no tags. HEX[-dirty] (note: no 'g' prefix) - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"]: - rendered += "-%%d-g%%s" %% (pieces["distance"], pieces["short"]) - else: - # exception #1 - rendered = pieces["short"] - if pieces["dirty"]: - rendered += "-dirty" - return rendered - - -def render_git_describe_long(pieces): - """TAG-DISTANCE-gHEX[-dirty]. - - Like 'git describe --tags --dirty --always -long'. - The distance/hash is unconditional. - - Exceptions: - 1: no tags. HEX[-dirty] (note: no 'g' prefix) - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - rendered += "-%%d-g%%s" %% (pieces["distance"], pieces["short"]) - else: - # exception #1 - rendered = pieces["short"] - if pieces["dirty"]: - rendered += "-dirty" - return rendered - - -def render(pieces, style): - """Render the given version pieces into the requested style.""" - if pieces["error"]: - return {"version": "unknown", - "full-revisionid": pieces.get("long"), - "dirty": None, - "error": pieces["error"], - "date": None} - - if not style or style == "default": - style = "pep440" # the default - - if style == "pep440": - rendered = render_pep440(pieces) - elif style == "pep440-pre": - rendered = render_pep440_pre(pieces) - elif style == "pep440-post": - rendered = render_pep440_post(pieces) - elif style == "pep440-old": - rendered = render_pep440_old(pieces) - elif style == "git-describe": - rendered = render_git_describe(pieces) - elif style == "git-describe-long": - rendered = render_git_describe_long(pieces) - else: - raise ValueError("unknown style '%%s'" %% style) - - return {"version": rendered, "full-revisionid": pieces["long"], - "dirty": pieces["dirty"], "error": None, - "date": pieces.get("date")} - - -def get_versions(): - """Get version information or return default if unable to do so.""" - # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have - # __file__, we can work backwards from there to the root. Some - # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which - # case we can only use expanded keywords. - - cfg = get_config() - verbose = cfg.verbose - - try: - return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, - verbose) - except NotThisMethod: - pass - - try: - root = os.path.realpath(__file__) - # versionfile_source is the relative path from the top of the source - # tree (where the .git directory might live) to this file. Invert - # this to find the root from __file__. - for i in cfg.versionfile_source.split('/'): - root = os.path.dirname(root) - except NameError: - return {"version": "0+unknown", "full-revisionid": None, - "dirty": None, - "error": "unable to find root of source tree", - "date": None} - - try: - pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose) - return render(pieces, cfg.style) - except NotThisMethod: - pass - - try: - if cfg.parentdir_prefix: - return versions_from_parentdir(cfg.parentdir_prefix, root, verbose) - except NotThisMethod: - pass - - return {"version": "0+unknown", "full-revisionid": None, - "dirty": None, - "error": "unable to compute version", "date": None} -''' - - -@register_vcs_handler("git", "get_keywords") -def git_get_keywords(versionfile_abs): - """Extract version information from the given file.""" - # the code embedded in _version.py can just fetch the value of these - # keywords. When used from setup.py, we don't want to import _version.py, - # so we do it with a regexp instead. This function is not used from - # _version.py. - keywords = {} - try: - f = open(versionfile_abs) - for line in f.readlines(): - if line.strip().startswith("git_refnames ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["refnames"] = mo.group(1) - if line.strip().startswith("git_full ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["full"] = mo.group(1) - if line.strip().startswith("git_date ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["date"] = mo.group(1) - f.close() - except OSError: - pass - return keywords - - -@register_vcs_handler("git", "keywords") -def git_versions_from_keywords(keywords, tag_prefix, verbose): - """Get version information from git keywords.""" - if not keywords: - raise NotThisMethod("no keywords at all, weird") - date = keywords.get("date") - if date is not None: - # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant - # datestamp. However we prefer "%ci" (which expands to an "ISO-8601 - # -like" string, which we must then edit to make compliant), because - # it's been around since git-1.5.3, and it's too difficult to - # discover which version we're using, or to work around using an - # older one. - date = date.strip().replace(" ", "T", 1).replace(" ", "", 1) - refnames = keywords["refnames"].strip() - if refnames.startswith("$Format"): - if verbose: - print("keywords are unexpanded, not using") - raise NotThisMethod("unexpanded keywords, not a git-archive tarball") - refs = {r.strip() for r in refnames.strip("()").split(",")} - # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of - # just "foo-1.0". If we see a "tag: " prefix, prefer those. - TAG = "tag: " - tags = {r[len(TAG) :] for r in refs if r.startswith(TAG)} - if not tags: - # Either we're using git < 1.8.3, or there really are no tags. We use - # a heuristic: assume all version tags have a digit. The old git %d - # expansion behaves like git log --decorate=short and strips out the - # refs/heads/ and refs/tags/ prefixes that would let us distinguish - # between branches and tags. By ignoring refnames without digits, we - # filter out many common branch names like "release" and - # "stabilization", as well as "HEAD" and "master". - tags = {r for r in refs if re.search(r"\d", r)} - if verbose: - print("discarding '%s', no digits" % ",".join(refs - tags)) - if verbose: - print("likely tags: %s" % ",".join(sorted(tags))) - for ref in sorted(tags): - # sorting will prefer e.g. "2.0" over "2.0rc1" - if ref.startswith(tag_prefix): - r = ref[len(tag_prefix) :] - if verbose: - print("picking %s" % r) - return { - "version": r, - "full-revisionid": keywords["full"].strip(), - "dirty": False, - "error": None, - "date": date, - } - # no suitable tags, so version is "0+unknown", but full hex is still there - if verbose: - print("no suitable tags, using unknown + full revision id") - return { - "version": "0+unknown", - "full-revisionid": keywords["full"].strip(), - "dirty": False, - "error": "no suitable tags", - "date": None, - } - - -@register_vcs_handler("git", "pieces_from_vcs") -def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): - """Get version from 'git describe' in the root of the source tree. - - This only gets called if the git-archive 'subst' keywords were *not* - expanded, and _version.py hasn't already been rewritten with a short - version string, meaning we're inside a checked out source tree. - """ - GITS = ["git"] - if sys.platform == "win32": - GITS = ["git.cmd", "git.exe"] - - out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, hide_stderr=True) - if rc != 0: - if verbose: - print("Directory %s not under git control" % root) - raise NotThisMethod("'git rev-parse --git-dir' returned error") - - # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] - # if there isn't one, this yields HEX[-dirty] (no NUM) - describe_out, rc = run_command( - GITS, ["describe", "--tags", "--dirty", "--always", "--long", "--match", "%s*" % tag_prefix], cwd=root - ) - # --long was added in git-1.5.5 - if describe_out is None: - raise NotThisMethod("'git describe' failed") - describe_out = describe_out.strip() - full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) - if full_out is None: - raise NotThisMethod("'git rev-parse' failed") - full_out = full_out.strip() - - pieces = {} - pieces["long"] = full_out - pieces["short"] = full_out[:7] # maybe improved later - pieces["error"] = None - - # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] - # TAG might have hyphens. - git_describe = describe_out - - # look for -dirty suffix - dirty = git_describe.endswith("-dirty") - pieces["dirty"] = dirty - if dirty: - git_describe = git_describe[: git_describe.rindex("-dirty")] - - # now we have TAG-NUM-gHEX or HEX - - if "-" in git_describe: - # TAG-NUM-gHEX - mo = re.search(r"^(.+)-(\d+)-g([0-9a-f]+)$", git_describe) - if not mo: - # unparseable. Maybe git-describe is misbehaving? - pieces["error"] = "unable to parse git-describe output: '%s'" % describe_out - return pieces - - # tag - full_tag = mo.group(1) - if not full_tag.startswith(tag_prefix): - if verbose: - fmt = "tag '%s' doesn't start with prefix '%s'" - print(fmt % (full_tag, tag_prefix)) - pieces["error"] = f"tag '{full_tag}' doesn't start with prefix '{tag_prefix}'" - return pieces - pieces["closest-tag"] = full_tag[len(tag_prefix) :] - - # distance: number of commits since tag - pieces["distance"] = int(mo.group(2)) - - # commit: short hex revision ID - pieces["short"] = mo.group(3) - - else: - # HEX: no tags - pieces["closest-tag"] = None - count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], cwd=root) - pieces["distance"] = int(count_out) # total number of commits - - # commit date: see ISO-8601 comment in git_versions_from_keywords() - date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip() - pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) - - return pieces - - -def do_vcs_install(manifest_in, versionfile_source, ipy): - """Git-specific installation logic for Versioneer. - - For Git, this means creating/changing .gitattributes to mark _version.py - for export-subst keyword substitution. - """ - GITS = ["git"] - if sys.platform == "win32": - GITS = ["git.cmd", "git.exe"] - files = [manifest_in, versionfile_source] - if ipy: - files.append(ipy) - try: - me = __file__ - if me.endswith(".pyc") or me.endswith(".pyo"): - me = os.path.splitext(me)[0] + ".py" - versioneer_file = os.path.relpath(me) - except NameError: - versioneer_file = "versioneer.py" - files.append(versioneer_file) - present = False - try: - f = open(".gitattributes") - for line in f.readlines(): - if line.strip().startswith(versionfile_source): - if "export-subst" in line.strip().split()[1:]: - present = True - f.close() - except OSError: - pass - if not present: - f = open(".gitattributes", "a+") - f.write("%s export-subst\n" % versionfile_source) - f.close() - files.append(".gitattributes") - run_command(GITS, ["add", "--"] + files) - - -def versions_from_parentdir(parentdir_prefix, root, verbose): - """Try to determine the version from the parent directory name. - - Source tarballs conventionally unpack into a directory that includes both - the project name and a version string. We will also support searching up - two directory levels for an appropriately named parent directory - """ - rootdirs = [] - - for i in range(3): - dirname = os.path.basename(root) - if dirname.startswith(parentdir_prefix): - return { - "version": dirname[len(parentdir_prefix) :], - "full-revisionid": None, - "dirty": False, - "error": None, - "date": None, - } - else: - rootdirs.append(root) - root = os.path.dirname(root) # up a level - - if verbose: - print(f"Tried directories {rootdirs!s} but none started with prefix {parentdir_prefix}") - raise NotThisMethod("rootdir doesn't start with parentdir_prefix") - - -SHORT_VERSION_PY = """ -# This file was generated by 'versioneer.py' (0.18) from -# revision-control system data, or from the parent directory name of an -# unpacked source archive. Distribution tarballs contain a pre-generated copy -# of this file. - -import json - -version_json = ''' -%s -''' # END VERSION_JSON - - -def get_versions(): - return json.loads(version_json) -""" - - -def versions_from_file(filename): - """Try to determine the version from _version.py if present.""" - try: - with open(filename) as f: - contents = f.read() - except OSError: - raise NotThisMethod("unable to read _version.py") - mo = re.search(r"version_json = '''\n(.*)''' # END VERSION_JSON", contents, re.M | re.S) - if not mo: - mo = re.search(r"version_json = '''\r\n(.*)''' # END VERSION_JSON", contents, re.M | re.S) - if not mo: - raise NotThisMethod("no version_json in _version.py") - return json.loads(mo.group(1)) - - -def write_to_version_file(filename, versions): - """Write the given version number to the given _version.py file.""" - os.unlink(filename) - contents = json.dumps(versions, sort_keys=True, indent=1, separators=(",", ": ")) - with open(filename, "w") as f: - f.write(SHORT_VERSION_PY % contents) - - print(f"set {filename} to '{versions['version']}'") - - -def plus_or_dot(pieces): - """Return a + if we don't already have one, else return a .""" - if "+" in pieces.get("closest-tag", ""): - return "." - return "+" - - -def render_pep440(pieces): - """Build up version string, with post-release "local version identifier". - - Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you - get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty - - Exceptions: - 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - rendered += plus_or_dot(pieces) - rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) - if pieces["dirty"]: - rendered += ".dirty" - else: - # exception #1 - rendered = "0+untagged.%d.g%s" % (pieces["distance"], pieces["short"]) - if pieces["dirty"]: - rendered += ".dirty" - return rendered - - -def render_pep440_pre(pieces): - """TAG[.post.devDISTANCE] -- No -dirty. - - Exceptions: - 1: no tags. 0.post.devDISTANCE - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"]: - rendered += ".post.dev%d" % pieces["distance"] - else: - # exception #1 - rendered = "0.post.dev%d" % pieces["distance"] - return rendered - - -def render_pep440_post(pieces): - """TAG[.postDISTANCE[.dev0]+gHEX] . - - The ".dev0" means dirty. Note that .dev0 sorts backwards - (a dirty tree will appear "older" than the corresponding clean one), - but you shouldn't be releasing software with -dirty anyways. - - Exceptions: - 1: no tags. 0.postDISTANCE[.dev0] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - rendered += ".post%d" % pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - rendered += plus_or_dot(pieces) - rendered += "g%s" % pieces["short"] - else: - # exception #1 - rendered = "0.post%d" % pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - rendered += "+g%s" % pieces["short"] - return rendered - - -def render_pep440_old(pieces): - """TAG[.postDISTANCE[.dev0]] . - - The ".dev0" means dirty. - - Eexceptions: - 1: no tags. 0.postDISTANCE[.dev0] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - rendered += ".post%d" % pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - else: - # exception #1 - rendered = "0.post%d" % pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - return rendered - - -def render_git_describe(pieces): - """TAG[-DISTANCE-gHEX][-dirty]. - - Like 'git describe --tags --dirty --always'. - - Exceptions: - 1: no tags. HEX[-dirty] (note: no 'g' prefix) - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"]: - rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) - else: - # exception #1 - rendered = pieces["short"] - if pieces["dirty"]: - rendered += "-dirty" - return rendered - - -def render_git_describe_long(pieces): - """TAG-DISTANCE-gHEX[-dirty]. - - Like 'git describe --tags --dirty --always -long'. - The distance/hash is unconditional. - - Exceptions: - 1: no tags. HEX[-dirty] (note: no 'g' prefix) - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) - else: - # exception #1 - rendered = pieces["short"] - if pieces["dirty"]: - rendered += "-dirty" - return rendered - - -def render(pieces, style): - """Render the given version pieces into the requested style.""" - if pieces["error"]: - return { - "version": "unknown", - "full-revisionid": pieces.get("long"), - "dirty": None, - "error": pieces["error"], - "date": None, - } - - if not style or style == "default": - style = "pep440" # the default - - if style == "pep440": - rendered = render_pep440(pieces) - elif style == "pep440-pre": - rendered = render_pep440_pre(pieces) - elif style == "pep440-post": - rendered = render_pep440_post(pieces) - elif style == "pep440-old": - rendered = render_pep440_old(pieces) - elif style == "git-describe": - rendered = render_git_describe(pieces) - elif style == "git-describe-long": - rendered = render_git_describe_long(pieces) - else: - raise ValueError("unknown style '%s'" % style) - - return { - "version": rendered, - "full-revisionid": pieces["long"], - "dirty": pieces["dirty"], - "error": None, - "date": pieces.get("date"), - } - - -class VersioneerBadRootError(Exception): - """The project root directory is unknown or missing key files.""" - - -def get_versions(verbose=False): - """Get the project version from whatever source is available. - - Returns dict with two keys: 'version' and 'full'. - """ - if "versioneer" in sys.modules: - # see the discussion in cmdclass.py:get_cmdclass() - del sys.modules["versioneer"] - - root = get_root() - cfg = get_config_from_root(root) - - assert cfg.VCS is not None, "please set [versioneer]VCS= in setup.cfg" - handlers = HANDLERS.get(cfg.VCS) - assert handlers, "unrecognized VCS '%s'" % cfg.VCS - verbose = verbose or cfg.verbose - assert cfg.versionfile_source is not None, "please set versioneer.versionfile_source" - assert cfg.tag_prefix is not None, "please set versioneer.tag_prefix" - - versionfile_abs = os.path.join(root, cfg.versionfile_source) - - # extract version from first of: _version.py, VCS command (e.g. 'git - # describe'), parentdir. This is meant to work for developers using a - # source checkout, for users of a tarball created by 'setup.py sdist', - # and for users of a tarball/zipball created by 'git archive' or github's - # download-from-tag feature or the equivalent in other VCSes. - - get_keywords_f = handlers.get("get_keywords") - from_keywords_f = handlers.get("keywords") - if get_keywords_f and from_keywords_f: - try: - keywords = get_keywords_f(versionfile_abs) - ver = from_keywords_f(keywords, cfg.tag_prefix, verbose) - if verbose: - print("got version from expanded keyword %s" % ver) - return ver - except NotThisMethod: - pass - - try: - ver = versions_from_file(versionfile_abs) - if verbose: - print(f"got version from file {versionfile_abs} {ver}") - return ver - except NotThisMethod: - pass - - from_vcs_f = handlers.get("pieces_from_vcs") - if from_vcs_f: - try: - pieces = from_vcs_f(cfg.tag_prefix, root, verbose) - ver = render(pieces, cfg.style) - if verbose: - print("got version from VCS %s" % ver) - return ver - except NotThisMethod: - pass - - try: - if cfg.parentdir_prefix: - ver = versions_from_parentdir(cfg.parentdir_prefix, root, verbose) - if verbose: - print("got version from parentdir %s" % ver) - return ver - except NotThisMethod: - pass - - if verbose: - print("unable to compute version") - - return { - "version": "0+unknown", - "full-revisionid": None, - "dirty": None, - "error": "unable to compute version", - "date": None, - } - - -def get_version(): - """Get the short version string for this project.""" - return get_versions()["version"] - - -def get_cmdclass(): - """Get the custom setuptools/distutils subclasses used by Versioneer.""" - if "versioneer" in sys.modules: - del sys.modules["versioneer"] - # this fixes the "python setup.py develop" case (also 'install' and - # 'easy_install .'), in which subdependencies of the main project are - # built (using setup.py bdist_egg) in the same python process. Assume - # a main project A and a dependency B, which use different versions - # of Versioneer. A's setup.py imports A's Versioneer, leaving it in - # sys.modules by the time B's setup.py is executed, causing B to run - # with the wrong versioneer. Setuptools wraps the sub-dep builds in a - # sandbox that restores sys.modules to it's pre-build state, so the - # parent is protected against the child's "import versioneer". By - # removing ourselves from sys.modules here, before the child build - # happens, we protect the child from the parent's versioneer too. - # Also see https://github.com/warner/python-versioneer/issues/52 - - cmds = {} - - # we add "version" to both distutils and setuptools - from distutils.core import Command - - class cmd_version(Command): - description = "report generated version string" - user_options = [] - boolean_options = [] - - def initialize_options(self): - pass - - def finalize_options(self): - pass - - def run(self): - vers = get_versions(verbose=True) - print("Version: %s" % vers["version"]) - print(" full-revisionid: %s" % vers.get("full-revisionid")) - print(" dirty: %s" % vers.get("dirty")) - print(" date: %s" % vers.get("date")) - if vers["error"]: - print(" error: %s" % vers["error"]) - - cmds["version"] = cmd_version - - # we override "build_py" in both distutils and setuptools - # - # most invocation pathways end up running build_py: - # distutils/build -> build_py - # distutils/install -> distutils/build ->.. - # setuptools/bdist_wheel -> distutils/install ->.. - # setuptools/bdist_egg -> distutils/install_lib -> build_py - # setuptools/install -> bdist_egg ->.. - # setuptools/develop -> ? - # pip install: - # copies source tree to a tempdir before running egg_info/etc - # if .git isn't copied too, 'git describe' will fail - # then does setup.py bdist_wheel, or sometimes setup.py install - # setup.py egg_info -> ? - - # we override different "build_py" commands for both environments - if "setuptools" in sys.modules: - from setuptools.command.build_py import build_py as _build_py - else: - from distutils.command.build_py import build_py as _build_py - - class cmd_build_py(_build_py): - def run(self): - root = get_root() - cfg = get_config_from_root(root) - versions = get_versions() - _build_py.run(self) - # now locate _version.py in the new build/ directory and replace - # it with an updated value - if cfg.versionfile_build: - target_versionfile = os.path.join(self.build_lib, cfg.versionfile_build) - print("UPDATING %s" % target_versionfile) - write_to_version_file(target_versionfile, versions) - - cmds["build_py"] = cmd_build_py - - if "cx_Freeze" in sys.modules: # cx_freeze enabled? - from cx_Freeze.dist import build_exe as _build_exe - - # nczeczulin reports that py2exe won't like the pep440-style string - # as FILEVERSION, but it can be used for PRODUCTVERSION, e.g. - # setup(console=[{ - # "version": versioneer.get_version().split("+", 1)[0], # FILEVERSION - # "product_version": versioneer.get_version(), - # ... - - class cmd_build_exe(_build_exe): - def run(self): - root = get_root() - cfg = get_config_from_root(root) - versions = get_versions() - target_versionfile = cfg.versionfile_source - print("UPDATING %s" % target_versionfile) - write_to_version_file(target_versionfile, versions) - - _build_exe.run(self) - os.unlink(target_versionfile) - with open(cfg.versionfile_source, "w") as f: - LONG = LONG_VERSION_PY[cfg.VCS] - f.write( - LONG - % { - "DOLLAR": "$", - "STYLE": cfg.style, - "TAG_PREFIX": cfg.tag_prefix, - "PARENTDIR_PREFIX": cfg.parentdir_prefix, - "VERSIONFILE_SOURCE": cfg.versionfile_source, - } - ) - - cmds["build_exe"] = cmd_build_exe - del cmds["build_py"] - - if "py2exe" in sys.modules: # py2exe enabled? - try: - from py2exe.distutils_buildexe import py2exe as _py2exe # py3 - except ImportError: - from py2exe.build_exe import py2exe as _py2exe # py2 - - class cmd_py2exe(_py2exe): - def run(self): - root = get_root() - cfg = get_config_from_root(root) - versions = get_versions() - target_versionfile = cfg.versionfile_source - print("UPDATING %s" % target_versionfile) - write_to_version_file(target_versionfile, versions) - - _py2exe.run(self) - os.unlink(target_versionfile) - with open(cfg.versionfile_source, "w") as f: - LONG = LONG_VERSION_PY[cfg.VCS] - f.write( - LONG - % { - "DOLLAR": "$", - "STYLE": cfg.style, - "TAG_PREFIX": cfg.tag_prefix, - "PARENTDIR_PREFIX": cfg.parentdir_prefix, - "VERSIONFILE_SOURCE": cfg.versionfile_source, - } - ) - - cmds["py2exe"] = cmd_py2exe - - # we override different "sdist" commands for both environments - if "setuptools" in sys.modules: - from setuptools.command.sdist import sdist as _sdist - else: - from distutils.command.sdist import sdist as _sdist - - class cmd_sdist(_sdist): - def run(self): - versions = get_versions() - self._versioneer_generated_versions = versions - # unless we update this, the command will keep using the old - # version - self.distribution.metadata.version = versions["version"] - return _sdist.run(self) - - def make_release_tree(self, base_dir, files): - root = get_root() - cfg = get_config_from_root(root) - _sdist.make_release_tree(self, base_dir, files) - # now locate _version.py in the new base_dir directory - # (remembering that it may be a hardlink) and replace it with an - # updated value - target_versionfile = os.path.join(base_dir, cfg.versionfile_source) - print("UPDATING %s" % target_versionfile) - write_to_version_file(target_versionfile, self._versioneer_generated_versions) - - cmds["sdist"] = cmd_sdist - - return cmds - - -CONFIG_ERROR = """ -setup.cfg is missing the necessary Versioneer configuration. You need -a section like: - - [versioneer] - VCS = git - style = pep440 - versionfile_source = src/myproject/_version.py - versionfile_build = myproject/_version.py - tag_prefix = - parentdir_prefix = myproject- - -You will also need to edit your setup.py to use the results: - - import versioneer - setup(version=versioneer.get_version(), - cmdclass=versioneer.get_cmdclass(), ...) - -Please read the docstring in ./versioneer.py for configuration instructions, -edit setup.cfg, and re-run the installer or 'python versioneer.py setup'. -""" - -SAMPLE_CONFIG = """ -# See the docstring in versioneer.py for instructions. Note that you must -# re-run 'versioneer.py setup' after changing this section, and commit the -# resulting files. - -[versioneer] -#VCS = git -#style = pep440 -#versionfile_source = -#versionfile_build = -#tag_prefix = -#parentdir_prefix = - -""" - -INIT_PY_SNIPPET = """ -from ._version import get_versions -__version__ = get_versions()['version'] -del get_versions -""" - - -def do_setup(): - """Main VCS-independent setup function for installing Versioneer.""" - root = get_root() - try: - cfg = get_config_from_root(root) - except (OSError, configparser.NoSectionError, configparser.NoOptionError) as e: - if isinstance(e, (EnvironmentError, configparser.NoSectionError)): - print("Adding sample versioneer config to setup.cfg", file=sys.stderr) - with open(os.path.join(root, "setup.cfg"), "a") as f: - f.write(SAMPLE_CONFIG) - print(CONFIG_ERROR, file=sys.stderr) - return 1 - - print(" creating %s" % cfg.versionfile_source) - with open(cfg.versionfile_source, "w") as f: - LONG = LONG_VERSION_PY[cfg.VCS] - f.write( - LONG - % { - "DOLLAR": "$", - "STYLE": cfg.style, - "TAG_PREFIX": cfg.tag_prefix, - "PARENTDIR_PREFIX": cfg.parentdir_prefix, - "VERSIONFILE_SOURCE": cfg.versionfile_source, - } - ) - - ipy = os.path.join(os.path.dirname(cfg.versionfile_source), "__init__.py") - if os.path.exists(ipy): - try: - with open(ipy) as f: - old = f.read() - except OSError: - old = "" - if INIT_PY_SNIPPET not in old: - print(" appending to %s" % ipy) - with open(ipy, "a") as f: - f.write(INIT_PY_SNIPPET) - else: - print(" %s unmodified" % ipy) - else: - print(" %s doesn't exist, ok" % ipy) - ipy = None - - # Make sure both the top-level "versioneer.py" and versionfile_source - # (PKG/_version.py, used by runtime code) are in MANIFEST.in, so - # they'll be copied into source distributions. Pip won't be able to - # install the package without this. - manifest_in = os.path.join(root, "MANIFEST.in") - simple_includes = set() - try: - with open(manifest_in) as f: - for line in f: - if line.startswith("include "): - for include in line.split()[1:]: - simple_includes.add(include) - except OSError: - pass - # That doesn't cover everything MANIFEST.in can do - # (http://docs.python.org/2/distutils/sourcedist.html#commands), so - # it might give some false negatives. Appending redundant 'include' - # lines is safe, though. - if "versioneer.py" not in simple_includes: - print(" appending 'versioneer.py' to MANIFEST.in") - with open(manifest_in, "a") as f: - f.write("include versioneer.py\n") - else: - print(" 'versioneer.py' already in MANIFEST.in") - if cfg.versionfile_source not in simple_includes: - print(" appending versionfile_source ('%s') to MANIFEST.in" % cfg.versionfile_source) - with open(manifest_in, "a") as f: - f.write("include %s\n" % cfg.versionfile_source) - else: - print(" versionfile_source already in MANIFEST.in") - - # Make VCS-specific changes. For git, this means creating/changing - # .gitattributes to mark _version.py for export-subst keyword - # substitution. - do_vcs_install(manifest_in, cfg.versionfile_source, ipy) - return 0 - - -def scan_setup_py(): - """Validate the contents of setup.py against Versioneer's expectations.""" - found = set() - setters = False - errors = 0 - with open("setup.py") as f: - for line in f.readlines(): - if "import versioneer" in line: - found.add("import") - if "versioneer.get_cmdclass()" in line: - found.add("cmdclass") - if "versioneer.get_version()" in line: - found.add("get_version") - if "versioneer.VCS" in line: - setters = True - if "versioneer.versionfile_source" in line: - setters = True - if len(found) != 3: - print("") - print("Your setup.py appears to be missing some important items") - print("(but I might be wrong). Please make sure it has something") - print("roughly like the following:") - print("") - print(" import versioneer") - print(" setup( version=versioneer.get_version(),") - print(" cmdclass=versioneer.get_cmdclass(), ...)") - print("") - errors += 1 - if setters: - print("You should remove lines like 'versioneer.VCS = ' and") - print("'versioneer.versionfile_source = ' . This configuration") - print("now lives in setup.cfg, and should be removed from setup.py") - print("") - errors += 1 - return errors - - -if __name__ == "__main__": - cmd = sys.argv[1] - if cmd == "setup": - errors = do_setup() - errors += scan_setup_py() - if errors: - sys.exit(1) From b9c36c1d7e2d4828f427656eee87c55a871e8e0f Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 3 Jan 2025 14:05:59 -0600 Subject: [PATCH 273/296] docs: docstrings for Pager and RequestOptions (#1498) * docs: docstrings for filter tooling * docs: docstring for Sort --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/server/pager.py | 24 ++++ tableauserverclient/server/request_options.py | 125 +++++++++++++++++- tableauserverclient/server/sort.py | 14 ++ 3 files changed, 161 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index e6d261b61..3c7e60f74 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -27,6 +27,30 @@ class Pager(Iterable[T]): (users in a group, views in a workbook, etc) by passing a different endpoint. Will loop over anything that returns (list[ModelItem], PaginationItem). + + Will make a copy of the `RequestOptions` object passed in so it can be reused. + + Makes a call to the Server for each page of items, then yields each item in the list. + + Parameters + ---------- + endpoint: CallableEndpoint[T] or Endpoint[T] + The endpoint to call to get the items. Can be a callable or an Endpoint object. + Expects a tuple of (list[T], PaginationItem) to be returned. + + request_opts: RequestOptions, optional + The request options to pass to the endpoint. If not provided, will use default RequestOptions. + Filters, sorts, page size, starting page number, etc can be set here. + + Yields + ------ + T + The items returned from the endpoint. + + Raises + ------ + ValueError + If the endpoint is not a callable or an Endpoint object. """ def __init__( diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 2a5bb805a..c37c0ce42 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -35,6 +35,28 @@ def apply_query_params(self, url): class RequestOptions(RequestOptionsBase): + """ + This class is used to manage the options that can be used when querying content on the server. + Optionally initialize with a page number and page size to control the number of items returned. + + Additionally, you can add sorting and filtering options to the request. + + The `sort` and `filter` options are set-like objects, so you can only add a field once. If you add the same field + multiple times, only the last one will be used. + + The list of fields that can be sorted on or filtered by can be found in the `Field` + class contained within this class. + + Parameters + ---------- + pagenumber: int, optional + The page number to start the query on. Default is 1. + + pagesize: int, optional + The number of items to return per page. Default is 100. Can also read + from the environment variable `TSC_PAGE_SIZE` + """ + def __init__(self, pagenumber=1, pagesize=None): self.pagenumber = pagenumber self.pagesize = pagesize or config.PAGE_SIZE @@ -199,13 +221,43 @@ def get_query_params(self): def vf(self, name: str, value: str) -> Self: """Apply a filter based on a column within the view. - Note that when filtering on a boolean type field, the only valid values are 'true' and 'false'""" + Note that when filtering on a boolean type field, the only valid values are 'true' and 'false' + + For more detail see: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_concepts_filtering_and_sorting.htm#Filter-query-views + + Parameters + ---------- + name: str + The name of the column to filter on + + value: str + The value to filter on + + Returns + ------- + Self + The current object + """ self.view_filters.append((name, value)) return self def parameter(self, name: str, value: str) -> Self: """Apply a filter based on a parameter within the workbook. - Note that when filtering on a boolean type field, the only valid values are 'true' and 'false'""" + Note that when filtering on a boolean type field, the only valid values are 'true' and 'false' + + Parameters + ---------- + name: str + The name of the parameter to filter on + + value: str + The value to filter on + + Returns + ------- + Self + The current object + """ self.view_parameters.append((name, value)) return self @@ -257,14 +309,60 @@ def get_query_params(self) -> dict: class CSVRequestOptions(_DataExportOptions): + """ + Options that can be used when exporting a view to CSV. Set the maxage to control the age of the data exported. + Filters to the underlying data can be applied using the `vf` and `parameter` methods. + + Parameters + ---------- + maxage: int, optional + The maximum age of the data to export. Shortest possible duration is 1 + minute. No upper limit. Default is -1, which means no limit. + """ + extension = "csv" class ExcelRequestOptions(_DataExportOptions): + """ + Options that can be used when exporting a view to Excel. Set the maxage to control the age of the data exported. + Filters to the underlying data can be applied using the `vf` and `parameter` methods. + + Parameters + ---------- + maxage: int, optional + The maximum age of the data to export. Shortest possible duration is 1 + minute. No upper limit. Default is -1, which means no limit. + """ + extension = "xlsx" class ImageRequestOptions(_ImagePDFCommonExportOptions): + """ + Options that can be used when exporting a view to an image. Set the maxage to control the age of the data exported. + Filters to the underlying data can be applied using the `vf` and `parameter` methods. + + Parameters + ---------- + imageresolution: str, optional + The resolution of the image to export. Valid values are "high" or None. Default is None. + Image width and actual pixel density are determined by the display context + of the image. Aspect ratio is always preserved. Set the value to "high" to + ensure maximum pixel density. + + maxage: int, optional + The maximum age of the data to export. Shortest possible duration is 1 + minute. No upper limit. Default is -1, which means no limit. + + viz_height: int, optional + The height of the viz in pixels. If specified, viz_width must also be specified. + + viz_width: int, optional + The width of the viz in pixels. If specified, viz_height must also be specified. + + """ + extension = "png" # if 'high' isn't specified, the REST API endpoint returns an image with standard resolution @@ -283,6 +381,29 @@ def get_query_params(self): class PDFRequestOptions(_ImagePDFCommonExportOptions): + """ + Options that can be used when exporting a view to PDF. Set the maxage to control the age of the data exported. + Filters to the underlying data can be applied using the `vf` and `parameter` methods. + + Parameters + ---------- + page_type: str, optional + The page type of the PDF to export. Valid values are accessible via the `PageType` class. + + orientation: str, optional + The orientation of the PDF to export. Valid values are accessible via the `Orientation` class. + + maxage: int, optional + The maximum age of the data to export. Shortest possible duration is 1 + minute. No upper limit. Default is -1, which means no limit. + + viz_height: int, optional + The height of the viz in pixels. If specified, viz_width must also be specified. + + viz_width: int, optional + The width of the viz in pixels. If specified, viz_height must also be specified. + """ + class PageType: A3 = "a3" A4 = "a4" diff --git a/tableauserverclient/server/sort.py b/tableauserverclient/server/sort.py index 839a8c8db..b78645921 100644 --- a/tableauserverclient/server/sort.py +++ b/tableauserverclient/server/sort.py @@ -1,4 +1,18 @@ class Sort: + """ + Used with request options (RequestOptions) where you can filter and sort on + the results returned from the server. + + Parameters + ---------- + field : str + Sets the field to sort on. The fields are defined in the RequestOption class. + + direction : str + The direction to sort, either ascending (Asc) or descending (Desc). The + options are defined in the RequestOptions.Direction class. + """ + def __init__(self, field, direction): self.field = field self.direction = direction From 55d592abb99d58ef4c331de778d8cc20a0ae6572 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 3 Jan 2025 14:42:45 -0600 Subject: [PATCH 274/296] docs: docstrings for group endpoint and item (#1499) --- tableauserverclient/models/group_item.py | 40 +++ .../server/endpoint/groups_endpoint.py | 336 +++++++++++++++++- 2 files changed, 365 insertions(+), 11 deletions(-) diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index 6871f8b16..0afd5582c 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -12,6 +12,46 @@ class GroupItem: + """ + The GroupItem class contains the attributes for the group resources on + Tableau Server. The GroupItem class defines the information you can request + or query from Tableau Server. The class members correspond to the attributes + of a server request or response payload. + + Parameters + ---------- + name: str + The name of the group. + + domain_name: str + The name of the Active Directory domain ("local" if local authentication is used). + + Properties + ---------- + users: Pager[UserItem] + The users in the group. Must be populated with a call to `populate_users()`. + + id: str + The unique identifier for the group. + + minimum_site_role: str + The minimum site role for users in the group. Use the `UserItem.Roles` enum. + Users in the group cannot have their site role set lower than this value. + + license_mode: str + The mode defining when to apply licenses for group members. When the + mode is onLogin, a license is granted for each group member when they + login to a site. When the mode is onSync, a license is granted for group + members each time the domain is synced. + + Examples + -------- + >>> # Create a new group item + >>> newgroup = TSC.GroupItem('My Group') + + + """ + tag_name: str = "group" class LicenseMode: diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index c512b011b..4e9af4076 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -8,7 +8,7 @@ from tableauserverclient.helpers.logging import logger -from typing import Optional, TYPE_CHECKING, Union +from typing import Literal, Optional, TYPE_CHECKING, Union, overload from collections.abc import Iterable from tableauserverclient.server.query import QuerySet @@ -18,13 +18,56 @@ class Groups(QuerysetEndpoint[GroupItem]): + """ + Groups endpoint for creating, reading, updating, and deleting groups on + Tableau Server. + """ + @property def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/groups" @api(version="2.0") def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[GroupItem], PaginationItem]: - """Gets all groups""" + """ + Returns information about the groups on the site. + + To get information about the users in a group, you must first populate + the GroupItem with user information using the groups.populate_users + method. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#query_groups + + Parameters + ---------- + req_options : Optional[RequestOptions] + (Optional) You can pass the method a request object that contains + additional parameters to filter the request. For example, if you + were searching for a specific group, you could specify the name of + the group or the group id. + + Returns + ------- + tuple[list[GroupItem], PaginationItem] + + Examples + -------- + >>> # import tableauserverclient as TSC + >>> # tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') + >>> # server = TSC.Server('https://SERVERURL') + + >>> with server.auth.sign_in(tableau_auth): + + >>> # get the groups on the server + >>> all_groups, pagination_item = server.groups.get() + + >>> # print the names of the first 100 groups + >>> for group in all_groups : + >>> print(group.name, group.id) + + + + """ logger.info("Querying all groups on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -34,7 +77,42 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Grou @api(version="2.0") def populate_users(self, group_item: GroupItem, req_options: Optional["RequestOptions"] = None) -> None: - """Gets all users in a given group""" + """ + Populates the group_item with the list of users. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#get_users_in_group + + Parameters + ---------- + group_item : GroupItem + The group item to populate with user information. + + req_options : Optional[RequestOptions] + (Optional) You can pass the method a request object that contains + page size and page number. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the group item does not have an ID, the method raises an error. + + Examples + -------- + >>> # Get the group item from the server + >>> groups, pagination_item = server.groups.get() + >>> group = groups[1] + + >>> # Populate the group with user information + >>> server.groups.populate_users(group) + >>> for user in group.users: + >>> print(user.name) + + + """ if not group_item.id: error = "Group item missing ID. Group must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -61,7 +139,32 @@ def _get_users_for_group( @api(version="2.0") def delete(self, group_id: str) -> None: - """Deletes 1 group by id""" + """ + Deletes the group on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#delete_group + + Parameters + ---------- + group_id: str + The id for the group you want to remove from the server + + Returns + ------- + None + + Raises + ------ + ValueError + If the group_id is not provided, the method raises an error. + + Examples + -------- + >>> groups, pagination_item = server.groups.get() + >>> group = groups[1] + >>> server.groups.delete(group.id) + + """ if not group_id: error = "Group ID undefined." raise ValueError(error) @@ -69,8 +172,42 @@ def delete(self, group_id: str) -> None: self.delete_request(url) logger.info(f"Deleted single group (ID: {group_id})") + @overload + def update(self, group_item: GroupItem, as_job: Literal[False]) -> GroupItem: ... + + @overload + def update(self, group_item: GroupItem, as_job: Literal[True]) -> JobItem: ... + @api(version="2.0") - def update(self, group_item: GroupItem, as_job: bool = False) -> Union[GroupItem, JobItem]: + def update(self, group_item, as_job=False): + """ + Updates a group on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#update_group + + Parameters + ---------- + group_item : GroupItem + The group item to update. + + as_job : bool + (Optional) If this value is set to True, the update operation will + be asynchronous and return a JobItem. This is only supported for + Active Directory groups. By default, this value is set to False. + + Returns + ------- + Union[GroupItem, JobItem] + + Raises + ------ + MissingRequiredFieldError + If the group_item does not have an ID, the method raises an error. + + ValueError + If the group_item is a local group and as_job is set to True, the + method raises an error. + """ url = f"{self.baseurl}/{group_item.id}" if not group_item.id: @@ -92,15 +229,71 @@ def update(self, group_item: GroupItem, as_job: bool = False) -> Union[GroupItem @api(version="2.0") def create(self, group_item: GroupItem) -> GroupItem: - """Create a 'local' Tableau group""" + """ + Create a 'local' Tableau group + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#create_group + + Parameters + ---------- + group_item : GroupItem + The group item to create. The group_item specifies the group to add. + You first create a new instance of a GroupItem and pass that to this + method. + + Returns + ------- + GroupItem + + Examples + -------- + >>> new_group = TSC.GroupItem('new_group') + >>> new_group.minimum_site_role = TSC.UserItem.Role.ExplorerCanPublish + >>> new_group = server.groups.create(new_group) + + """ url = self.baseurl create_req = RequestFactory.Group.create_local_req(group_item) server_response = self.post_request(url, create_req) return GroupItem.from_response(server_response.content, self.parent_srv.namespace)[0] + @overload + def create_AD_group(self, group_item: GroupItem, asJob: Literal[False]) -> GroupItem: ... + + @overload + def create_AD_group(self, group_item: GroupItem, asJob: Literal[True]) -> JobItem: ... + @api(version="2.0") - def create_AD_group(self, group_item: GroupItem, asJob: bool = False) -> Union[GroupItem, JobItem]: - """Create a group based on Active Directory""" + def create_AD_group(self, group_item, asJob=False): + """ + Create a group based on Active Directory. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#create_group + + Parameters + ---------- + group_item : GroupItem + The group item to create. The group_item specifies the group to add. + You first create a new instance of a GroupItem and pass that to this + method. + + asJob : bool + (Optional) If this value is set to True, the create operation will + be asynchronous and return a JobItem. This is only supported for + Active Directory groups. By default, this value is set to False. + + Returns + ------- + Union[GroupItem, JobItem] + + Examples + -------- + >>> new_ad_group = TSC.GroupItem('new_ad_group') + >>> new_ad_group.domain_name = 'example.com' + >>> new_ad_group.minimum_site_role = TSC.UserItem.Role.ExplorerCanPublish + >>> new_ad_group.license_mode = TSC.GroupItem.LicenseMode.onSync + >>> new_ad_group = server.groups.create_AD_group(new_ad_group) + """ asJobparameter = "?asJob=true" if asJob else "" url = self.baseurl + asJobparameter create_req = RequestFactory.Group.create_ad_req(group_item) @@ -112,7 +305,37 @@ def create_AD_group(self, group_item: GroupItem, asJob: bool = False) -> Union[G @api(version="2.0") def remove_user(self, group_item: GroupItem, user_id: str) -> None: - """Removes 1 user from 1 group""" + """ + Removes 1 user from 1 group + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#remove_user_to_group + + Parameters + ---------- + group_item : GroupItem + The group item from which to remove the user. + + user_id : str + The ID of the user to remove from the group. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the group_item does not have an ID, the method raises an error. + + ValueError + If the user_id is not provided, the method raises an error. + + Examples + -------- + >>> group = server.groups.get_by_id('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + >>> server.groups.populate_users(group) + >>> server.groups.remove_user(group, group.users[0].id) + """ if not group_item.id: error = "Group item missing ID." raise MissingRequiredFieldError(error) @@ -125,7 +348,37 @@ def remove_user(self, group_item: GroupItem, user_id: str) -> None: @api(version="3.21") def remove_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> None: - """Removes multiple users from 1 group""" + """ + Removes multiple users from 1 group. This makes a single API call to + remove the provided users. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#remove_users_to_group + + Parameters + ---------- + group_item : GroupItem + The group item from which to remove the user. + + users : Iterable[Union[str, UserItem]] + The IDs or UserItems with IDs of the users to remove from the group. + + Returns + ------- + None + + Raises + ------ + ValueError + If the group_item is not a GroupItem or str, the method raises an error. + + Examples + -------- + >>> group = server.groups.get_by_id('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + >>> server.groups.populate_users(group) + >>> users = [u for u in group.users if u.domain_name == 'example.com'] + >>> server.groups.remove_users(group, users) + + """ group_id = group_item.id if hasattr(group_item, "id") else group_item if not isinstance(group_id, str): raise ValueError(f"Invalid group provided: {group_item}") @@ -138,7 +391,37 @@ def remove_users(self, group_item: GroupItem, users: Iterable[Union[str, UserIte @api(version="2.0") def add_user(self, group_item: GroupItem, user_id: str) -> UserItem: - """Adds 1 user to 1 group""" + """ + Adds 1 user to 1 group + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#add_user_to_group + + Parameters + ---------- + group_item : GroupItem + The group item to which to add the user. + + user_id : str + The ID of the user to add to the group. + + Returns + ------- + UserItem + UserItem for the user that was added to the group. + + Raises + ------ + MissingRequiredFieldError + If the group_item does not have an ID, the method raises an error. + + ValueError + If the user_id is not provided, the method raises an error. + + Examples + -------- + >>> group = server.groups.get_by_id('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + >>> server.groups.add_user(group, '1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + """ if not group_item.id: error = "Group item missing ID." raise MissingRequiredFieldError(error) @@ -154,6 +437,37 @@ def add_user(self, group_item: GroupItem, user_id: str) -> UserItem: @api(version="3.21") def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> list[UserItem]: + """ + Adds 1 or more user to 1 group + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#add_user_to_group + + Parameters + ---------- + group_item : GroupItem + The group item to which to add the user. + + user_id : Iterable[Union[str, UserItem]] + User IDs or UserItems with IDs to add to the group. + + Returns + ------- + list[UserItem] + UserItem for the user that was added to the group. + + Raises + ------ + MissingRequiredFieldError + If the group_item does not have an ID, the method raises an error. + + ValueError + If the user_id is not provided, the method raises an error. + + Examples + -------- + >>> group = server.groups.get_by_id('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + >>> added_users = server.groups.add_users(group, '1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + """ """Adds multiple users to 1 group""" group_id = group_item.id if hasattr(group_item, "id") else group_item if not isinstance(group_id, str): From f9341a4be947c96c7b5e0d850aac374bcad5bc6f Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 3 Jan 2025 15:08:58 -0600 Subject: [PATCH 275/296] docs: project docstrings (#1505) * docs: project docstrings * docs: add REST API links --- tableauserverclient/models/project_item.py | 38 ++ .../server/endpoint/projects_endpoint.py | 600 ++++++++++++++++++ 2 files changed, 638 insertions(+) diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index b20cb5374..9be1196ba 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -9,6 +9,44 @@ class ProjectItem: + """ + The project resources for Tableau are defined in the ProjectItem class. The + class corresponds to the project resources you can access using the Tableau + Server REST API. + + Parameters + ---------- + name : str + Name of the project. + + description : str + Description of the project. + + content_permissions : str + Sets or shows the permissions for the content in the project. The + options are either LockedToProject, ManagedByOwner or + LockedToProjectWithoutNested. + + parent_id : str + The id of the parent project. Use this option to create project + hierarchies. For information about managing projects, project + hierarchies, and permissions, see + https://help.tableau.com/current/server/en-us/projects.htm + + samples : bool + Set to True to include sample workbooks and data sources in the + project. The default is False. + + Attributes + ---------- + id : str + The unique identifier for the project. + + owner_id : str + The unique identifier for the UserItem owner of the project. + + """ + ERROR_MSG = "Project item must be populated with permissions first." class ContentPermissions: diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 74bb865c7..68eb573cc 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -20,6 +20,11 @@ class Projects(QuerysetEndpoint[ProjectItem]): + """ + The project methods are based upon the endpoints for projects in the REST + API and operate on the ProjectItem class. + """ + def __init__(self, parent_srv: "Server") -> None: super().__init__(parent_srv) @@ -32,6 +37,23 @@ def baseurl(self) -> str: @api(version="2.0") def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[ProjectItem], PaginationItem]: + """ + Retrieves all projects on the site. The endpoint is paginated and can + be filtered using the req_options parameter. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_projects.htm#query_projects + + Parameters + ---------- + req_options : RequestOptions | None, default None + The request options to filter the projects. The default is None. + + Returns + ------- + tuple[list[ProjectItem], PaginationItem] + Returns a tuple containing a list of ProjectItem objects and a + PaginationItem object. + """ logger.info("Querying all projects on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -41,6 +63,25 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Proj @api(version="2.0") def delete(self, project_id: str) -> None: + """ + Deletes a single project on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_projects.htm#delete_project + + Parameters + ---------- + project_id : str + The unique identifier for the project. + + Returns + ------- + None + + Raises + ------ + ValueError + If the project ID is not defined, an error is raised. + """ if not project_id: error = "Project ID undefined." raise ValueError(error) @@ -50,6 +91,36 @@ def delete(self, project_id: str) -> None: @api(version="2.0") def update(self, project_item: ProjectItem, samples: bool = False) -> ProjectItem: + """ + Modify the project settings. + + You can use this method to update the project name, the project + description, or the project permissions. To specify the site, create a + TableauAuth instance using the content URL for the site (site_id), and + sign in to that site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_projects.htm#update_project + + Parameters + ---------- + project_item : ProjectItem + The project item object must include the project ID. The values in + the project item override the current project settings. + + samples : bool + Set to True to include sample workbooks and data sources in the + project. The default is False. + + Returns + ------- + ProjectItem + Returns the updated project item. + + Raises + ------ + MissingRequiredFieldError + If the project item is missing the ID, an error is raised. + """ if not project_item.id: error = "Project item missing ID." raise MissingRequiredFieldError(error) @@ -64,6 +135,32 @@ def update(self, project_item: ProjectItem, samples: bool = False) -> ProjectIte @api(version="2.0") def create(self, project_item: ProjectItem, samples: bool = False) -> ProjectItem: + """ + Creates a project on the specified site. + + To create a project, you first create a new instance of a ProjectItem + and pass it to the create method. To specify the site to create the new + project, create a TableauAuth instance using the content URL for the + site (site_id), and sign in to that site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_projects.htm#create_project + + Parameters + ---------- + project_item : ProjectItem + Specifies the properties for the project. The project_item is the + request package. To create the request package, create a new + instance of ProjectItem. + + samples : bool + Set to True to include sample workbooks and data sources in the + project. The default is False. + + Returns + ------- + ProjectItem + Returns the new project item. + """ params = {"params": {RequestOptions.Field.PublishSamples: samples}} url = self.baseurl if project_item._samples: @@ -76,136 +173,639 @@ def create(self, project_item: ProjectItem, samples: bool = False) -> ProjectIte @api(version="2.0") def populate_permissions(self, item: ProjectItem) -> None: + """ + Queries the project permissions, parses and stores the returned the permissions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_project_permissions + + Parameters + ---------- + item : ProjectItem + The project item to populate with permissions. + + Returns + ------- + None + """ self._permissions.populate(item) @api(version="2.0") def update_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: + """ + Updates the permissions for the specified project item. The rules + provided are expected to be a complete list of the permissions for the + project. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_permissions_for_content + + Parameters + ---------- + item : ProjectItem + The project item to update permissions for. + + rules : list[PermissionsRule] + The list of permissions rules to update the project with. + + Returns + ------- + list[PermissionsRule] + Returns the updated list of permissions rules. + """ + return self._permissions.update(item, rules) @api(version="2.0") def delete_permission(self, item: ProjectItem, rules: list[PermissionsRule]) -> None: + """ + Deletes the specified permissions from the project item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_project_permission + + Parameters + ---------- + item : ProjectItem + The project item to delete permissions from. + + rules : list[PermissionsRule] + The list of permissions rules to delete from the project. + + Returns + ------- + None + """ self._permissions.delete(item, rules) @api(version="2.1") def populate_workbook_default_permissions(self, item: ProjectItem) -> None: + """ + Queries the default workbook permissions, parses and stores the + returned the permissions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions + + Parameters + ---------- + item : ProjectItem + The project item to populate with default workbook permissions. + + Returns + ------- + None + """ self._default_permissions.populate_default_permissions(item, Resource.Workbook) @api(version="2.1") def populate_datasource_default_permissions(self, item: ProjectItem) -> None: + """ + Queries the default datasource permissions, parses and stores the + returned the permissions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions + + Parameters + ---------- + item : ProjectItem + The project item to populate with default datasource permissions. + + Returns + ------- + None + """ self._default_permissions.populate_default_permissions(item, Resource.Datasource) @api(version="3.2") def populate_metric_default_permissions(self, item: ProjectItem) -> None: + """ + Queries the default metric permissions, parses and stores the + returned the permissions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions + + Parameters + ---------- + item : ProjectItem + The project item to populate with default metric permissions. + + Returns + ------- + None + """ self._default_permissions.populate_default_permissions(item, Resource.Metric) @api(version="3.4") def populate_datarole_default_permissions(self, item: ProjectItem) -> None: + """ + Queries the default datarole permissions, parses and stores the + returned the permissions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions + + Parameters + ---------- + item : ProjectItem + The project item to populate with default datarole permissions. + + Returns + ------- + None + """ self._default_permissions.populate_default_permissions(item, Resource.Datarole) @api(version="3.4") def populate_flow_default_permissions(self, item: ProjectItem) -> None: + """ + Queries the default flow permissions, parses and stores the + returned the permissions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions + + Parameters + ---------- + item : ProjectItem + The project item to populate with default flow permissions. + + Returns + ------- + None + """ self._default_permissions.populate_default_permissions(item, Resource.Flow) @api(version="3.4") def populate_lens_default_permissions(self, item: ProjectItem) -> None: + """ + Queries the default lens permissions, parses and stores the + returned the permissions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions + + Parameters + ---------- + item : ProjectItem + The project item to populate with default lens permissions. + + Returns + ------- + None + """ self._default_permissions.populate_default_permissions(item, Resource.Lens) @api(version="3.23") def populate_virtualconnection_default_permissions(self, item: ProjectItem) -> None: + """ + Queries the default virtualconnections permissions, parses and stores + the returned the permissions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions + + Parameters + ---------- + item : ProjectItem + The project item to populate with default virtual connection + permissions. + + Returns + ------- + None + """ self._default_permissions.populate_default_permissions(item, Resource.VirtualConnection) @api(version="3.23") def populate_database_default_permissions(self, item: ProjectItem) -> None: + """ + Queries the default database permissions, parses and stores the + returned the permissions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions + + Parameters + ---------- + item : ProjectItem + The project item to populate with default database permissions. + + Returns + ------- + None + """ self._default_permissions.populate_default_permissions(item, Resource.Database) @api(version="3.23") def populate_table_default_permissions(self, item: ProjectItem) -> None: + """ + Queries the default table permissions, parses and stores the + returned the permissions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions + + Parameters + ---------- + item : ProjectItem + The project item to populate with default table permissions. + + Returns + ------- + None + """ self._default_permissions.populate_default_permissions(item, Resource.Table) @api(version="2.1") def update_workbook_default_permissions( self, item: ProjectItem, rules: list[PermissionsRule] ) -> list[PermissionsRule]: + """ + Add or updates the default workbook permissions for the specified. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content + + Parameters + ---------- + item : ProjectItem + The project item to update default workbook permissions for. + + rules : list[PermissionsRule] + The list of permissions rules to update the project with. + + Returns + ------- + list[PermissionsRule] + Returns the updated list of permissions rules. + """ return self._default_permissions.update_default_permissions(item, rules, Resource.Workbook) @api(version="2.1") def update_datasource_default_permissions( self, item: ProjectItem, rules: list[PermissionsRule] ) -> list[PermissionsRule]: + """ + Add or updates the default datasource permissions for the specified. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content + + Parameters + ---------- + item : ProjectItem + The project item to update default datasource permissions for. + + rules : list[PermissionsRule] + The list of permissions rules to update the project with. + + Returns + ------- + list[PermissionsRule] + Returns the updated list of permissions rules. + """ return self._default_permissions.update_default_permissions(item, rules, Resource.Datasource) @api(version="3.2") def update_metric_default_permissions( self, item: ProjectItem, rules: list[PermissionsRule] ) -> list[PermissionsRule]: + """ + Add or updates the default metric permissions for the specified. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content + + Parameters + ---------- + item : ProjectItem + The project item to update default metric permissions for. + + rules : list[PermissionsRule] + The list of permissions rules to update the project with. + + Returns + ------- + list[PermissionsRule] + Returns the updated list of permissions rules. + """ return self._default_permissions.update_default_permissions(item, rules, Resource.Metric) @api(version="3.4") def update_datarole_default_permissions( self, item: ProjectItem, rules: list[PermissionsRule] ) -> list[PermissionsRule]: + """ + Add or updates the default datarole permissions for the specified. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content + + Parameters + ---------- + item : ProjectItem + The project item to update default datarole permissions for. + + rules : list[PermissionsRule] + The list of permissions rules to update the project with. + + Returns + ------- + list[PermissionsRule] + Returns the updated list of permissions rules. + """ return self._default_permissions.update_default_permissions(item, rules, Resource.Datarole) @api(version="3.4") def update_flow_default_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: + """ + Add or updates the default flow permissions for the specified. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content + + Parameters + ---------- + item : ProjectItem + The project item to update default flow permissions for. + + rules : list[PermissionsRule] + The list of permissions rules to update the project with. + + Returns + ------- + list[PermissionsRule] + Returns the updated list of permissions rules. + """ return self._default_permissions.update_default_permissions(item, rules, Resource.Flow) @api(version="3.4") def update_lens_default_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: + """ + Add or updates the default lens permissions for the specified. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content + + Parameters + ---------- + item : ProjectItem + The project item to update default lens permissions for. + + rules : list[PermissionsRule] + The list of permissions rules to update the project with. + + Returns + ------- + list[PermissionsRule] + Returns the updated list of permissions rules. + """ return self._default_permissions.update_default_permissions(item, rules, Resource.Lens) @api(version="3.23") def update_virtualconnection_default_permissions( self, item: ProjectItem, rules: list[PermissionsRule] ) -> list[PermissionsRule]: + """ + Add or updates the default virtualconnection permissions for the specified. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content + + Parameters + ---------- + item : ProjectItem + The project item to update default virtualconnection permissions for. + + rules : list[PermissionsRule] + The list of permissions rules to update the project with. + + Returns + ------- + list[PermissionsRule] + Returns the updated list of permissions rules. + """ return self._default_permissions.update_default_permissions(item, rules, Resource.VirtualConnection) @api(version="3.23") def update_database_default_permissions( self, item: ProjectItem, rules: list[PermissionsRule] ) -> list[PermissionsRule]: + """ + Add or updates the default database permissions for the specified. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content + + Parameters + ---------- + item : ProjectItem + The project item to update default database permissions for. + + rules : list[PermissionsRule] + The list of permissions rules to update the project with. + + Returns + ------- + list[PermissionsRule] + Returns the updated list of permissions rules. + """ return self._default_permissions.update_default_permissions(item, rules, Resource.Database) @api(version="3.23") def update_table_default_permissions( self, item: ProjectItem, rules: list[PermissionsRule] ) -> list[PermissionsRule]: + """ + Add or updates the default table permissions for the specified. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content + + Parameters + ---------- + item : ProjectItem + The project item to update default table permissions for. + + rules : list[PermissionsRule] + The list of permissions rules to update the project with. + + Returns + ------- + list[PermissionsRule] + Returns the updated list of permissions rules. + """ return self._default_permissions.update_default_permissions(item, rules, Resource.Table) @api(version="2.1") def delete_workbook_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + """ + Deletes the specified default permission rule from the project. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission + + Parameters + ---------- + item : ProjectItem + The project item to delete default workbook permissions from. + + rule : PermissionsRule + The permissions rule to delete from the project. + + Returns + ------- + None + """ self._default_permissions.delete_default_permission(item, rule, Resource.Workbook) @api(version="2.1") def delete_datasource_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + """ + Deletes the specified default permission rule from the project. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission + + Parameters + ---------- + item : ProjectItem + The project item to delete default datasource permissions from. + + rule : PermissionsRule + The permissions rule to delete from the project. + + Returns + ------- + None + """ self._default_permissions.delete_default_permission(item, rule, Resource.Datasource) @api(version="3.2") def delete_metric_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + """ + Deletes the specified default permission rule from the project. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission + + Parameters + ---------- + item : ProjectItem + The project item to delete default workbook permissions from. + + rule : PermissionsRule + The permissions rule to delete from the project. + + Returns + ------- + None + """ self._default_permissions.delete_default_permission(item, rule, Resource.Metric) @api(version="3.4") def delete_datarole_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + """ + Deletes the specified default permission rule from the project. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission + + Parameters + ---------- + item : ProjectItem + The project item to delete default datarole permissions from. + + rule : PermissionsRule + The permissions rule to delete from the project. + + Returns + ------- + None + """ self._default_permissions.delete_default_permission(item, rule, Resource.Datarole) @api(version="3.4") def delete_flow_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + """ + Deletes the specified default permission rule from the project. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission + + Parameters + ---------- + item : ProjectItem + The project item to delete default flow permissions from. + + rule : PermissionsRule + The permissions rule to delete from the project. + + Returns + ------- + None + """ self._default_permissions.delete_default_permission(item, rule, Resource.Flow) @api(version="3.4") def delete_lens_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + """ + Deletes the specified default permission rule from the project. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission + + Parameters + ---------- + item : ProjectItem + The project item to delete default lens permissions from. + + rule : PermissionsRule + The permissions rule to delete from the project. + + Returns + ------- + None + """ self._default_permissions.delete_default_permission(item, rule, Resource.Lens) @api(version="3.23") def delete_virtualconnection_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + """ + Deletes the specified default permission rule from the project. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission + + Parameters + ---------- + item : ProjectItem + The project item to delete default virtualconnection permissions from. + + rule : PermissionsRule + The permissions rule to delete from the project. + + Returns + ------- + None + """ self._default_permissions.delete_default_permission(item, rule, Resource.VirtualConnection) @api(version="3.23") def delete_database_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + """ + Deletes the specified default permission rule from the project. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission + + Parameters + ---------- + item : ProjectItem + The project item to delete default database permissions from. + + rule : PermissionsRule + The permissions rule to delete from the project. + + Returns + ------- + None + """ self._default_permissions.delete_default_permission(item, rule, Resource.Database) @api(version="3.23") def delete_table_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + """ + Deletes the specified default permission rule from the project. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission + + Parameters + ---------- + item : ProjectItem + The project item to delete default table permissions from. + + rule : PermissionsRule + The permissions rule to delete from the project. + + Returns + ------- + None + """ self._default_permissions.delete_default_permission(item, rule, Resource.Table) def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[ProjectItem]: From a43355aec83b2517a932d786440221248e6eb95d Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 3 Jan 2025 15:11:41 -0600 Subject: [PATCH 276/296] docs: docstrings for JobItem and Jobs endpoint (#1529) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/job_item.py | 65 +++++++++++ .../server/endpoint/jobs_endpoint.py | 109 ++++++++++++++++++ 2 files changed, 174 insertions(+) diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index cc7cd5811..6286275c5 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -8,6 +8,71 @@ class JobItem: + """ + Using the TSC library, you can get information about an asynchronous process + (or job) on the server. These jobs can be created when Tableau runs certain + tasks that could be long running, such as importing or synchronizing users + from Active Directory, or running an extract refresh. For example, the REST + API methods to create or update groups, to run an extract refresh task, or + to publish workbooks can take an asJob parameter (asJob-true) that creates a + background process (the job) to complete the call. Information about the + asynchronous job is returned from the method. + + If you have the identifier of the job, you can use the TSC library to find + out the status of the asynchronous job. + + The job properties are defined in the JobItem class. The class corresponds + to the properties for jobs you can access using the Tableau Server REST API. + The job methods are based upon the endpoints for jobs in the REST API and + operate on the JobItem class. + + Parameters + ---------- + id_ : str + The identifier of the job. + + job_type : str + The type of job. + + progress : str + The progress of the job. + + created_at : datetime.datetime + The date and time the job was created. + + started_at : Optional[datetime.datetime] + The date and time the job was started. + + completed_at : Optional[datetime.datetime] + The date and time the job was completed. + + finish_code : int + The finish code of the job. 0 for success, 1 for failure, 2 for cancelled. + + notes : Optional[list[str]] + Contains detailed notes about the job. + + mode : Optional[str] + + workbook_id : Optional[str] + The identifier of the workbook associated with the job. + + datasource_id : Optional[str] + The identifier of the datasource associated with the job. + + flow_run : Optional[FlowRunItem] + The flow run associated with the job. + + updated_at : Optional[datetime.datetime] + The date and time the job was last updated. + + workbook_name : Optional[str] + The name of the workbook associated with the job. + + datasource_name : Optional[str] + The name of the datasource associated with the job. + """ + class FinishCode: """ Status codes as documented on diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index 723d3dd38..027a7ca12 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -33,6 +33,32 @@ def get(self: Self, job_id: None, req_options: Optional[RequestOptionsBase]) -> @api(version="2.6") def get(self, job_id=None, req_options=None): + """ + Retrieve jobs for the site. Endpoint is paginated and will return a + list of jobs and pagination information. If a job_id is provided, the + method will return information about that specific job. Specifying a + job_id is deprecated and will be removed in a future version. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#query_jobs + + Parameters + ---------- + job_id : str or RequestOptionsBase + The ID of the job to retrieve. If None, the method will return all + jobs for the site. If a RequestOptions object is provided, the + method will use the options to filter the jobs. + + req_options : RequestOptionsBase + The request options to filter the jobs. If None, the method will + return all jobs for the site. + + Returns + ------- + tuple[list[BackgroundJobItem], PaginationItem] or JobItem + If a job_id is provided, the method will return a JobItem. If no + job_id is provided, the method will return a tuple containing a + list of BackgroundJobItems and a PaginationItem. + """ # Backwards Compatibility fix until we rev the major version if job_id is not None and isinstance(job_id, str): import warnings @@ -50,6 +76,33 @@ def get(self, job_id=None, req_options=None): @api(version="3.1") def cancel(self, job_id: Union[str, JobItem]): + """ + Cancels a job specified by job ID. To get a list of job IDs for jobs that are currently queued or in-progress, use the Query Jobs method. + + The following jobs can be canceled using the Cancel Job method: + + Full extract refresh + Incremental extract refresh + Subscription + Flow Run + Data Acceleration (Data acceleration is not available in Tableau Server 2022.1 (API 3.16) and later. See View Acceleration(Link opens in a new window).) + Bridge full extract refresh + Bridge incremental extract refresh + Queue upgrade Thumbnail (Job that puts the upgrade thumbnail job on the queue) + Upgrade Thumbnail + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#cancel_job + + Parameters + ---------- + job_id : str or JobItem + The ID of the job to cancel. If a JobItem is provided, the method + will use the ID from the JobItem. + + Returns + ------- + None + """ if isinstance(job_id, JobItem): job_id = job_id.id assert isinstance(job_id, str) @@ -58,6 +111,32 @@ def cancel(self, job_id: Union[str, JobItem]): @api(version="2.6") def get_by_id(self, job_id: str) -> JobItem: + """ + Returns status information about an asynchronous process that is tracked + using a job. This method can be used to query jobs that are used to do + the following: + + Import users from Active Directory (the result of a call to Create Group). + Synchronize an existing Tableau Server group with Active Directory (the result of a call to Update Group). + Run extract refresh tasks (the result of a call to Run Extract Refresh Task). + Publish a workbook asynchronously (the result of a call to Publish Workbook). + Run workbook or view subscriptions (the result of a call to Create Subscription or Update Subscription) + Run a flow task (the result of a call to Run Flow Task) + Status of Tableau Server site deletion (the result of a call to asynchronous Delete Site(Link opens in a new window) beginning API 3.18) + Note: To query a site deletion job, the server administrator must be first signed into the default site (contentUrl=" "). + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#query_job + + Parameters + ---------- + job_id : str + The ID of the job to retrieve. + + Returns + ------- + JobItem + The JobItem object that contains information about the requested job. + """ logger.info("Query for information about job " + job_id) url = f"{self.baseurl}/{job_id}" server_response = self.get_request(url) @@ -65,6 +144,36 @@ def get_by_id(self, job_id: str) -> JobItem: return new_job def wait_for_job(self, job_id: Union[str, JobItem], *, timeout: Optional[float] = None) -> JobItem: + """ + Waits for a job to complete. The method will poll the server for the job + status until the job is completed. If the job is successful, the method + will return the JobItem. If the job fails, the method will raise a + JobFailedException. If the job is cancelled, the method will raise a + JobCancelledException. + + Parameters + ---------- + job_id : str or JobItem + The ID of the job to wait for. If a JobItem is provided, the method + will use the ID from the JobItem. + + timeout : float | None + The maximum amount of time to wait for the job to complete. If None, + the method will wait indefinitely. + + Returns + ------- + JobItem + The JobItem object that contains information about the completed job. + + Raises + ------ + JobFailedException + If the job failed to complete. + + JobCancelledException + If the job was cancelled. + """ if isinstance(job_id, JobItem): job_id = job_id.id assert isinstance(job_id, str) From 1020485db3dd37b475562e75a1f58a59ad8d9d98 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 3 Jan 2025 16:27:12 -0600 Subject: [PATCH 277/296] docs: docstrings on ConnectionItem and ConnectionCredentials (#1526) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- .../models/connection_credentials.py | 18 ++++++++- tableauserverclient/models/connection_item.py | 38 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/models/connection_credentials.py b/tableauserverclient/models/connection_credentials.py index bb2cbbba9..aaa2f1bed 100644 --- a/tableauserverclient/models/connection_credentials.py +++ b/tableauserverclient/models/connection_credentials.py @@ -2,11 +2,27 @@ class ConnectionCredentials: - """Connection Credentials for Workbooks and Datasources publish request. + """ + Connection Credentials for Workbooks and Datasources publish request. Consider removing this object and other variables holding secrets as soon as possible after use to avoid them hanging around in memory. + Parameters + ---------- + name: str + The username for the connection. + + password: str + The password used for the connection. + + embed: bool, default True + Determines whether to embed the password (True) for the workbook or data source connection or not (False). + + oauth: bool, default False + Determines whether to use OAuth for the connection (True) or not (False). + For more information see: https://help.tableau.com/current/server/en-us/protected_auth.htm + """ def __init__(self, name, password, embed=True, oauth=False): diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 937e43481..e68958c3b 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -9,6 +9,44 @@ class ConnectionItem: + """ + Corresponds to workbook and data source connections. + + Attributes + ---------- + datasource_id: str + The identifier of the data source. + + datasource_name: str + The name of the data source. + + id: str + The identifier of the connection. + + connection_type: str + The type of connection. + + username: str + The username for the connection. (see ConnectionCredentials) + + password: str + The password used for the connection. (see ConnectionCredentials) + + embed_password: bool + Determines whether to embed the password (True) for the workbook or data source connection or not (False). (see ConnectionCredentials) + + server_address: str + The server address for the connection. + + server_port: str + The port used for the connection. + + connection_credentials: ConnectionCredentials + The Connection Credentials object containing authentication details for + the connection. Replaces username/password/embed_password when + publishing a flow, document or workbook file in the request body. + """ + def __init__(self): self._datasource_id: Optional[str] = None self._datasource_name: Optional[str] = None From 20354813bea41244d0e13001bad010c1f107e2e3 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 3 Jan 2025 16:34:35 -0600 Subject: [PATCH 278/296] docs: flow docstrings (#1532) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/flow_item.py | 53 ++- .../server/endpoint/flows_endpoint.py | 314 ++++++++++++++++++ 2 files changed, 363 insertions(+), 4 deletions(-) diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index 9bcad5e89..0083776bb 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -1,7 +1,7 @@ import copy import datetime import xml.etree.ElementTree as ET -from typing import Optional +from typing import Iterable, Optional from defusedxml.ElementTree import fromstring @@ -15,6 +15,51 @@ class FlowItem: + """ + Represents a Tableau Flow item. + + Parameters + ---------- + project_id: str + The ID of the project that the flow belongs to. + + name: Optional[str] + The name of the flow. + + Attributes + ---------- + connections: Iterable[ConnectionItem] + The connections associated with the flow. This property is not populated + by default and must be populated by calling the `populate_connections` + method. + + created_at: Optional[datetime.datetime] + The date and time when the flow was created. + + description: Optional[str] + The description of the flow. + + dqws: Iterable[DQWItem] + The data quality warnings associated with the flow. This property is not + populated by default and must be populated by calling the `populate_dqws` + method. + + id: Optional[str] + The ID of the flow. + + name: Optional[str] + The name of the flow. + + owner_id: Optional[str] + The ID of the user who owns the flow. + + project_name: Optional[str] + The name of the project that the flow belongs to. + + tags: set[str] + The tags associated with the flow. + """ + def __repr__(self): return " None: self.tags: set[str] = set() self.description: Optional[str] = None - self._connections: Optional[ConnectionItem] = None - self._permissions: Optional[Permission] = None - self._data_quality_warnings: Optional[DQWItem] = None + self._connections: Optional[Iterable[ConnectionItem]] = None + self._permissions: Optional[Iterable[Permission]] = None + self._data_quality_warnings: Optional[Iterable[DQWItem]] = None @property def connections(self): diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 7eb5dc3ba..42c9d4c1e 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -66,6 +66,25 @@ def baseurl(self) -> str: # Get all flows @api(version="3.3") def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[FlowItem], PaginationItem]: + """ + Get all flows on site. Returns a tuple of all flow items and pagination item. + This method is paginated, and returns one page of items per call. The + request options can be used to specify the page number, page size, as + well as sorting and filtering options. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#query_flows_for_site + + Parameters + ---------- + req_options: Optional[RequestOptions] + An optional Request Options object that can be used to specify + sorting, filtering, and pagination options. + + Returns + ------- + tuple[list[FlowItem], PaginationItem] + A tuple of a list of flow items and a pagination item. + """ logger.info("Querying all flows on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -76,6 +95,21 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Flow # Get 1 flow by id @api(version="3.3") def get_by_id(self, flow_id: str) -> FlowItem: + """ + Get a single flow by id. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#query_flow + + Parameters + ---------- + flow_id: str + The id of the flow to retrieve. + + Returns + ------- + FlowItem + The flow item that was retrieved. + """ if not flow_id: error = "Flow ID undefined." raise ValueError(error) @@ -87,6 +121,27 @@ def get_by_id(self, flow_id: str) -> FlowItem: # Populate flow item's connections @api(version="3.3") def populate_connections(self, flow_item: FlowItem) -> None: + """ + Populate the connections for a flow item. This method will make a + request to the Tableau Server to get the connections associated with + the flow item and populate the connections property of the flow item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#query_flow_connections + + Parameters + ---------- + flow_item: FlowItem + The flow item to populate connections for. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the flow item does not have an ID. + """ if not flow_item.id: error = "Flow item missing ID. Flow must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -106,6 +161,25 @@ def _get_flow_connections(self, flow_item, req_options: Optional["RequestOptions # Delete 1 flow by id @api(version="3.3") def delete(self, flow_id: str) -> None: + """ + Delete a single flow by id. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#delete_flow + + Parameters + ---------- + flow_id: str + The id of the flow to delete. + + Returns + ------- + None + + Raises + ------ + ValueError + If the flow_id is not defined. + """ if not flow_id: error = "Flow ID undefined." raise ValueError(error) @@ -116,6 +190,35 @@ def delete(self, flow_id: str) -> None: # Download 1 flow by id @api(version="3.3") def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> PathOrFileW: + """ + Download a single flow by id. The flow will be downloaded to the + specified file path. If no file path is specified, the flow will be + downloaded to the current working directory. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#download_flow + + Parameters + ---------- + flow_id: str + The id of the flow to download. + + filepath: Optional[PathOrFileW] + The file path to download the flow to. This can be a file path or + a file object. If a file object is passed, the flow will be written + to the file object. If a file path is passed, the flow will be + written to the file path. If no file path is specified, the flow + will be downloaded to the current working directory. + + Returns + ------- + PathOrFileW + The file path or file object that the flow was downloaded to. + + Raises + ------ + ValueError + If the flow_id is not defined. + """ if not flow_id: error = "Flow ID undefined." raise ValueError(error) @@ -144,6 +247,21 @@ def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> Path # Update flow @api(version="3.3") def update(self, flow_item: FlowItem) -> FlowItem: + """ + Updates the flow owner, project, description, and/or tags. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#update_flow + + Parameters + ---------- + flow_item: FlowItem + The flow item to update. + + Returns + ------- + FlowItem + The updated flow item. + """ if not flow_item.id: error = "Flow item missing ID. Flow must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -161,6 +279,25 @@ def update(self, flow_item: FlowItem) -> FlowItem: # Update flow connections @api(version="3.3") def update_connection(self, flow_item: FlowItem, connection_item: ConnectionItem) -> ConnectionItem: + """ + Update a connection item for a flow item. This method will update the + connection details for the connection item associated with the flow. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#update_flow_connection + + Parameters + ---------- + flow_item: FlowItem + The flow item that the connection is associated with. + + connection_item: ConnectionItem + The connection item to update. + + Returns + ------- + ConnectionItem + The updated connection item. + """ url = f"{self.baseurl}/{flow_item.id}/connections/{connection_item.id}" update_req = RequestFactory.Connection.update_req(connection_item) @@ -172,6 +309,21 @@ def update_connection(self, flow_item: FlowItem, connection_item: ConnectionItem @api(version="3.3") def refresh(self, flow_item: FlowItem) -> JobItem: + """ + Runs the flow to refresh the data. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#run_flow_now + + Parameters + ---------- + flow_item: FlowItem + The flow item to refresh. + + Returns + ------- + JobItem + The job item that was created to refresh the flow. + """ url = f"{self.baseurl}/{flow_item.id}/run" empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) @@ -183,6 +335,35 @@ def refresh(self, flow_item: FlowItem) -> JobItem: def publish( self, flow_item: FlowItem, file: PathOrFileR, mode: str, connections: Optional[list[ConnectionItem]] = None ) -> FlowItem: + """ + Publishes a flow to the Tableau Server. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#publish_flow + + Parameters + ---------- + flow_item: FlowItem + The flow item to publish. This item must have a project_id and name + defined. + + file: PathOrFileR + The file path or file object to publish. This can be a .tfl or .tflx + + mode: str + The publish mode. This can be "Overwrite" or "CreatNew". If the + mode is "Overwrite", the flow will be overwritten if it already + exists. If the mode is "CreateNew", a new flow will be created with + the same name as the flow item. + + connections: Optional[list[ConnectionItem]] + A list of connection items to publish with the flow. If the flow + contains connections, they must be included in this list. + + Returns + ------- + FlowItem + The flow item that was published. + """ if not mode or not hasattr(self.parent_srv.PublishMode, mode): error = "Invalid mode defined." raise ValueError(error) @@ -265,30 +446,145 @@ def publish( @api(version="3.3") def populate_permissions(self, item: FlowItem) -> None: + """ + Populate the permissions for a flow item. This method will make a + request to the Tableau Server to get the permissions associated with + the flow item and populate the permissions property of the flow item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#query_flow_permissions + + Parameters + ---------- + item: FlowItem + The flow item to populate permissions for. + + Returns + ------- + None + """ self._permissions.populate(item) @api(version="3.3") def update_permissions(self, item: FlowItem, permission_item: Iterable["PermissionsRule"]) -> None: + """ + Update the permissions for a flow item. This method will update the + permissions for the flow item. The permissions must be a list of + permissions rules. Will overwrite all existing permissions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_permissions_for_content + + Parameters + ---------- + item: FlowItem + The flow item to update permissions for. + + permission_item: Iterable[PermissionsRule] + The permissions rules to update. + + Returns + ------- + None + """ self._permissions.update(item, permission_item) @api(version="3.3") def delete_permission(self, item: FlowItem, capability_item: "PermissionsRule") -> None: + """ + Delete a permission for a flow item. This method will delete only the + specified permission for the flow item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#delete_flow_permission + + Parameters + ---------- + item: FlowItem + The flow item to delete the permission from. + + capability_item: PermissionsRule + The permission to delete. + + Returns + ------- + None + """ self._permissions.delete(item, capability_item) @api(version="3.5") def populate_dqw(self, item: FlowItem) -> None: + """ + Get information about Data Quality Warnings for a flow item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_dqws + + Parameters + ---------- + item: FlowItem + The flow item to populate data quality warnings for. + + Returns + ------- + None + """ self._data_quality_warnings.populate(item) @api(version="3.5") def update_dqw(self, item: FlowItem, warning: "DQWItem") -> None: + """ + Update the warning type, status, and message of a data quality warning + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#update_dqw + + Parameters + ---------- + item: FlowItem + The flow item to update data quality warnings for. + + warning: DQWItem + The data quality warning to update. + + Returns + ------- + None + """ return self._data_quality_warnings.update(item, warning) @api(version="3.5") def add_dqw(self, item: FlowItem, warning: "DQWItem") -> None: + """ + Add a data quality warning to a flow. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#add_dqw + + Parameters + ---------- + item: FlowItem + The flow item to add data quality warnings to. + + warning: DQWItem + The data quality warning to add. + + Returns + ------- + None + """ return self._data_quality_warnings.add(item, warning) @api(version="3.5") def delete_dqw(self, item: FlowItem) -> None: + """ + Delete all data quality warnings for a flow. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#delete_dqws + + Parameters + ---------- + item: FlowItem + The flow item to delete data quality warnings from. + + Returns + ------- + None + """ self._data_quality_warnings.clear(item) # a convenience method @@ -296,6 +592,24 @@ def delete_dqw(self, item: FlowItem) -> None: def schedule_flow_run( self, schedule_id: str, item: FlowItem ) -> list["AddResponse"]: # actually should return a task + """ + Schedule a flow to run on an existing schedule. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#add_flow_task_to_schedule + + Parameters + ---------- + schedule_id: str + The id of the schedule to add the flow to. + + item: FlowItem + The flow item to add to the schedule. + + Returns + ------- + list[AddResponse] + The response from the server. + """ return self.parent_srv.schedules.add_to_schedule(schedule_id, flow=item) def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[FlowItem]: From ec10c60af783410c6afec6e95d745af43bece11f Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 3 Jan 2025 17:55:46 -0600 Subject: [PATCH 279/296] docs: docstrings for views (#1523) * docs: docstrings for views --- tableauserverclient/models/view_item.py | 58 ++++- .../server/endpoint/views_endpoint.py | 245 +++++++++++++++++- 2 files changed, 298 insertions(+), 5 deletions(-) diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index dc5f37a48..88cec7328 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -7,12 +7,64 @@ from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime -from .exceptions import UnpopulatedPropertyError -from .permissions_item import PermissionsRule -from .tag_item import TagItem +from tableauserverclient.models.exceptions import UnpopulatedPropertyError +from tableauserverclient.models.permissions_item import PermissionsRule +from tableauserverclient.models.tag_item import TagItem class ViewItem: + """ + Contains the members or attributes for the view resources on Tableau Server. + The ViewItem class defines the information you can request or query from + Tableau Server. The class members correspond to the attributes of a server + request or response payload. + + Attributes + ---------- + content_url: Optional[str], default None + The name of the view as it would appear in a URL. + + created_at: Optional[datetime], default None + The date and time when the view was created. + + id: Optional[str], default None + The unique identifier for the view. + + image: Optional[Callable[[], bytes]], default None + The image of the view. You must first call the `views.populate_image` + method to access the image. + + name: Optional[str], default None + The name of the view. + + owner_id: Optional[str], default None + The ID for the owner of the view. + + pdf: Optional[Callable[[], bytes]], default None + The PDF of the view. You must first call the `views.populate_pdf` + method to access the PDF. + + preview_image: Optional[Callable[[], bytes]], default None + The preview image of the view. You must first call the + `views.populate_preview_image` method to access the preview image. + + project_id: Optional[str], default None + The ID for the project that contains the view. + + tags: set[str], default set() + The tags associated with the view. + + total_views: Optional[int], default None + The total number of views for the view. + + updated_at: Optional[datetime], default None + The date and time when the view was last updated. + + workbook_id: Optional[str], default None + The ID for the workbook that contains the view. + + """ + def __init__(self) -> None: self._content_url: Optional[str] = None self._created_at: Optional[datetime] = None diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 3709fc41d..12b386876 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -1,6 +1,7 @@ import logging from contextlib import closing +from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint @@ -25,6 +26,12 @@ class Views(QuerysetEndpoint[ViewItem], TaggingMixin[ViewItem]): + """ + The Tableau Server Client provides methods for interacting with view + resources, or endpoints. These methods correspond to the endpoints for views + in the Tableau Server REST API. + """ + def __init__(self, parent_srv): super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) @@ -42,6 +49,24 @@ def baseurl(self) -> str: def get( self, req_options: Optional["RequestOptions"] = None, usage: bool = False ) -> tuple[list[ViewItem], PaginationItem]: + """ + Returns the list of views on the site. Paginated endpoint. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#query_views_for_site + + Parameters + ---------- + req_options: Optional[RequestOptions], default None + The request options for the request. These options can include + parameters such as page size and sorting. + + usage: bool, default False + If True, includes usage statistics in the response. + + Returns + ------- + views: tuple[list[ViewItem], PaginationItem] + """ logger.info("Querying all views on site") url = self.baseurl if usage: @@ -53,6 +78,23 @@ def get( @api(version="3.1") def get_by_id(self, view_id: str, usage: bool = False) -> ViewItem: + """ + Returns the details of a specific view. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#get_view + + Parameters + ---------- + view_id: str + The view ID. + + usage: bool, default False + If True, includes usage statistics in the response. + + Returns + ------- + view_item: ViewItem + """ if not view_id: error = "View item missing ID." raise MissingRequiredFieldError(error) @@ -65,6 +107,24 @@ def get_by_id(self, view_id: str, usage: bool = False) -> ViewItem: @api(version="2.0") def populate_preview_image(self, view_item: ViewItem) -> None: + """ + Populates a preview image for the specified view. + + This method gets the preview image (thumbnail) for the specified view + item. The method uses the id and workbook_id fields to query the preview + image. The method populates the preview_image for the view. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#query_view_with_preview + + Parameters + ---------- + view_item: ViewItem + The view item for which to populate the preview image. + + Returns + ------- + None + """ if not view_item.id or not view_item.workbook_id: error = "View item missing ID or workbook ID." raise MissingRequiredFieldError(error) @@ -83,6 +143,27 @@ def _get_preview_for_view(self, view_item: ViewItem) -> bytes: @api(version="2.5") def populate_image(self, view_item: ViewItem, req_options: Optional["ImageRequestOptions"] = None) -> None: + """ + Populates the image of the specified view. + + This method uses the id field to query the image, and populates the + image content as the image field. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#query_view_image + + Parameters + ---------- + view_item: ViewItem + The view item for which to populate the image. + + req_options: Optional[ImageRequestOptions], default None + Optional request options for the request. These options can include + parameters such as image resolution and max age. + + Returns + ------- + None + """ if not view_item.id: error = "View item missing ID." raise MissingRequiredFieldError(error) @@ -101,6 +182,26 @@ def _get_view_image(self, view_item: ViewItem, req_options: Optional["ImageReque @api(version="2.7") def populate_pdf(self, view_item: ViewItem, req_options: Optional["PDFRequestOptions"] = None) -> None: + """ + Populates the PDF content of the specified view. + + This method populates a PDF with image(s) of the view you specify. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#query_view_pdf + + Parameters + ---------- + view_item: ViewItem + The view item for which to populate the PDF. + + req_options: Optional[PDFRequestOptions], default None + Optional request options for the request. These options can include + parameters such as orientation and paper size. + + Returns + ------- + None + """ if not view_item.id: error = "View item missing ID." raise MissingRequiredFieldError(error) @@ -119,6 +220,27 @@ def _get_view_pdf(self, view_item: ViewItem, req_options: Optional["PDFRequestOp @api(version="2.7") def populate_csv(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"] = None) -> None: + """ + Populates the CSV data of the specified view. + + This method uses the id field to query the CSV data, and populates the + data as the csv field. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#query_view_data + + Parameters + ---------- + view_item: ViewItem + The view item for which to populate the CSV data. + + req_options: Optional[CSVRequestOptions], default None + Optional request options for the request. These options can include + parameters such as view filters and max age. + + Returns + ------- + None + """ if not view_item.id: error = "View item missing ID." raise MissingRequiredFieldError(error) @@ -137,6 +259,27 @@ def _get_view_csv(self, view_item: ViewItem, req_options: Optional["CSVRequestOp @api(version="3.8") def populate_excel(self, view_item: ViewItem, req_options: Optional["ExcelRequestOptions"] = None) -> None: + """ + Populates the Excel data of the specified view. + + This method uses the id field to query the Excel data, and populates the + data as the Excel field. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#download_view_excel + + Parameters + ---------- + view_item: ViewItem + The view item for which to populate the Excel data. + + req_options: Optional[ExcelRequestOptions], default None + Optional request options for the request. These options can include + parameters such as view filters and max age. + + Returns + ------- + None + """ if not view_item.id: error = "View item missing ID." raise MissingRequiredFieldError(error) @@ -155,18 +298,66 @@ def _get_view_excel(self, view_item: ViewItem, req_options: Optional["ExcelReque @api(version="3.2") def populate_permissions(self, item: ViewItem) -> None: + """ + Returns a list of permissions for the specific view. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_view_permissions + + Parameters + ---------- + item: ViewItem + The view item for which to populate the permissions. + + Returns + ------- + None + """ self._permissions.populate(item) @api(version="3.2") - def update_permissions(self, resource, rules): + def update_permissions(self, resource: ViewItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: + """ """ return self._permissions.update(resource, rules) @api(version="3.2") - def delete_permission(self, item, capability_item): + def delete_permission(self, item: ViewItem, capability_item: PermissionsRule) -> None: + """ + Deletes permission to the specified view (also known as a sheet) for a + Tableau Server user or group. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_view_permission + + Parameters + ---------- + item: ViewItem + The view item for which to delete the permission. + + capability_item: PermissionsRule + The permission rule to delete. + + Returns + ------- + None + """ return self._permissions.delete(item, capability_item) # Update view. Currently only tags can be updated def update(self, view_item: ViewItem) -> ViewItem: + """ + Updates the tags for the specified view. All other fields are managed + through the WorkbookItem object. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#add_tags_to_view + + Parameters + ---------- + view_item: ViewItem + The view item for which to update tags. + + Returns + ------- + ViewItem + """ if not view_item.id: error = "View item missing ID. View must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -178,14 +369,64 @@ def update(self, view_item: ViewItem) -> ViewItem: @api(version="1.0") def add_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) -> set[str]: + """ + Adds tags to the specified view. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#add_tags_to_view + + Parameters + ---------- + item: Union[ViewItem, str] + The view item or view ID to which to add tags. + + tags: Union[Iterable[str], str] + The tags to add to the view. + + Returns + ------- + set[str] + + """ return super().add_tags(item, tags) @api(version="1.0") def delete_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) -> None: + """ + Deletes tags from the specified view. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#delete_tags_from_view + + Parameters + ---------- + item: Union[ViewItem, str] + The view item or view ID from which to delete tags. + + tags: Union[Iterable[str], str] + The tags to delete from the view. + + Returns + ------- + None + """ return super().delete_tags(item, tags) @api(version="1.0") def update_tags(self, item: ViewItem) -> None: + """ + Updates the tags for the specified view. Any changes to the tags must + be made by editing the tags attribute of the view item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#add_tags_to_view + + Parameters + ---------- + item: ViewItem + The view item for which to update tags. + + Returns + ------- + None + """ return super().update_tags(item) def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[ViewItem]: From 3275925df6f1284d21b3a9a3137a2a583b32708b Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 10 Jan 2025 03:33:25 -0600 Subject: [PATCH 280/296] Jorwoods/datasource refresh hotfix (#1554) * hotfix: Datasource refresh expects empty requests. Closes #1553 --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- .../server/endpoint/datasources_endpoint.py | 3 ++- test/test_datasource.py | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 1f00af570..5a48f3c93 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -190,7 +190,8 @@ def update_connection( def refresh(self, datasource_item: DatasourceItem, incremental: bool = False) -> JobItem: id_ = getattr(datasource_item, "id", datasource_item) url = f"{self.baseurl}/{id_}/refresh" - refresh_req = RequestFactory.Task.refresh_req(incremental) + # refresh_req = RequestFactory.Task.refresh_req(incremental) + refresh_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, refresh_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job diff --git a/test/test_datasource.py b/test/test_datasource.py index e8a95722b..b7e7e2721 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -366,6 +366,25 @@ def test_refresh_object(self) -> None: # We only check the `id`; remaining fields are already tested in `test_refresh_id` self.assertEqual("7c3d599e-949f-44c3-94a1-f30ba85757e4", new_job.id) + def test_datasource_refresh_request_empty(self) -> None: + self.server.version = "2.8" + self.baseurl = self.server.datasources.baseurl + item = TSC.DatasourceItem("") + item._id = "1234" + text = read_xml_asset(REFRESH_XML) + + def match_request_body(request): + try: + root = fromstring(request.body) + assert root.tag == "tsRequest" + assert len(root) == 0 + return True + except Exception: + return False + + with requests_mock.mock() as m: + m.post(f"{self.baseurl}/1234/refresh", text=text, additional_matcher=match_request_body) + def test_update_hyper_data_datasource_object(self) -> None: """Calling `update_hyper_data` with a `DatasourceItem` should update that datasource""" self.server.version = "3.13" From 19128068e29f02009fb0b7a978710dd35af7a548 Mon Sep 17 00:00:00 2001 From: Dmytro Kulyk <34435869+KulykDmytro@users.noreply.github.com> Date: Thu, 23 Jan 2025 01:45:12 +0200 Subject: [PATCH 281/296] Fixed incorrect size unit when logging fileUpload (#1560) Update fileuploads_endpoint.py fix size unit when logging fileUpload Co-authored-by: Jac --- tableauserverclient/server/endpoint/fileuploads_endpoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/fileuploads_endpoint.py b/tableauserverclient/server/endpoint/fileuploads_endpoint.py index 1ae10e72d..c1749af40 100644 --- a/tableauserverclient/server/endpoint/fileuploads_endpoint.py +++ b/tableauserverclient/server/endpoint/fileuploads_endpoint.py @@ -56,6 +56,6 @@ def upload(self, file): request, content_type = RequestFactory.Fileupload.chunk_req(chunk) logger.debug(f"{datetime.timestamp()} created chunk request") fileupload_item = self.append(upload_id, request, content_type) - logger.info(f"\t{datetime.timestamp()} Published {(fileupload_item.file_size / BYTES_PER_MB)}MB") + logger.info(f"\t{datetime.timestamp()} Published {fileupload_item.file_size}MB") logger.info(f"File upload finished (ID: {upload_id})") return upload_id From 364f4313b3973355491a499bad892f8a579709b2 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 23 Jan 2025 03:49:11 -0600 Subject: [PATCH 282/296] docs: DatasourceItem and Endpoint docstrings (#1556) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/datasource_item.py | 129 +++- .../server/endpoint/datasources_endpoint.py | 560 +++++++++++++++++- 2 files changed, 666 insertions(+), 23 deletions(-) diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 1b082c157..2005edf7e 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -19,6 +19,93 @@ class DatasourceItem: + """ + Represents a Tableau datasource item. + + Parameters + ---------- + project_id : Optional[str] + The project ID that the datasource belongs to. + + name : Optional[str] + The name of the datasource. + + Attributes + ---------- + ask_data_enablement : Optional[str] + Determines if a data source allows use of Ask Data. The value can be + TSC.DatasourceItem.AskDataEnablement.Enabled, + TSC.DatasourceItem.AskDataEnablement.Disabled, or + TSC.DatasourceItem.AskDataEnablement.SiteDefault. If no setting is + specified, it will default to SiteDefault. See REST API Publish + Datasource for more information about ask_data_enablement. + + connections : list[ConnectionItem] + The list of data connections (ConnectionItem) for the specified data + source. You must first call the populate_connections method to access + this data. See the ConnectionItem class. + + content_url : Optional[str] + The name of the data source as it would appear in a URL. + + created_at : Optional[datetime.datetime] + The time the data source was created. + + certified : Optional[bool] + A Boolean value that indicates whether the data source is certified. + + certification_note : Optional[str] + The optional note that describes the certified data source. + + datasource_type : Optional[str] + The type of data source, for example, sqlserver or excel-direct. + + description : Optional[str] + The description for the data source. + + encrypt_extracts : Optional[bool] + A Boolean value to determine if a datasource should be encrypted or not. + See Extract and Encryption Methods for more information. + + has_extracts : Optional[bool] + A Boolean value that indicates whether the datasource has extracts. + + id : Optional[str] + The identifier for the data source. You need this value to query a + specific data source or to delete a data source with the get_by_id and + delete methods. + + name : Optional[str] + The name of the data source. If not specified, the name of the published + data source file is used. + + owner_id : Optional[str] + The identifier of the owner of the data source. + + project_id : Optional[str] + The identifier of the project associated with the data source. You must + provide this identifier when you create an instance of a DatasourceItem. + + project_name : Optional[str] + The name of the project associated with the data source. + + tags : Optional[set[str]] + The tags (list of strings) that have been added to the data source. + + updated_at : Optional[datetime.datetime] + The date and time when the data source was last updated. + + use_remote_query_agent : Optional[bool] + A Boolean value that indicates whether to allow or disallow your Tableau + Cloud site to use Tableau Bridge clients. Bridge allows you to maintain + data sources with live connections to supported on-premises data + sources. See Configure and Manage the Bridge Client Pool for more + information. + + webpage_url : Optional[str] + The url of the datasource as displayed in browsers. + """ + class AskDataEnablement: Enabled = "Enabled" Disabled = "Disabled" @@ -33,28 +120,28 @@ def __repr__(self): ) def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) -> None: - self._ask_data_enablement = None - self._certified = None - self._certification_note = None - self._connections = None + self._ask_data_enablement: Optional[str] = None + self._certified: Optional[bool] = None + self._certification_note: Optional[str] = None + self._connections: Optional[list[ConnectionItem]] = None self._content_url: Optional[str] = None - self._created_at = None - self._datasource_type = None - self._description = None - self._encrypt_extracts = None - self._has_extracts = None + self._created_at: Optional[datetime.datetime] = None + self._datasource_type: Optional[str] = None + self._description: Optional[str] = None + self._encrypt_extracts: Optional[bool] = None + self._has_extracts: Optional[bool] = None self._id: 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 - self.description = None - self.name = name + self._updated_at: Optional[datetime.datetime] = None + self._use_remote_query_agent: Optional[bool] = None + self._webpage_url: Optional[str] = None + self.description: Optional[str] = None + self.name: Optional[str] = name self.owner_id: Optional[str] = None - self.project_id = project_id + self.project_id: Optional[str] = project_id self.tags: set[str] = set() self._permissions = None @@ -63,16 +150,16 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) return None @property - def ask_data_enablement(self) -> Optional[AskDataEnablement]: + def ask_data_enablement(self) -> Optional[str]: return self._ask_data_enablement @ask_data_enablement.setter @property_is_enum(AskDataEnablement) - def ask_data_enablement(self, value: Optional[AskDataEnablement]): + def ask_data_enablement(self, value: Optional[str]): self._ask_data_enablement = value @property - def connections(self) -> Optional[list[ConnectionItem]]: + def connections(self): if self._connections is None: error = "Datasource item must be populated with connections first." raise UnpopulatedPropertyError(error) @@ -112,7 +199,7 @@ def certification_note(self, value: Optional[str]): self._certification_note = value @property - def encrypt_extracts(self): + def encrypt_extracts(self) -> Optional[bool]: return self._encrypt_extracts @encrypt_extracts.setter @@ -156,7 +243,7 @@ def description(self) -> Optional[str]: return self._description @description.setter - def description(self, value: str): + def description(self, value: Optional[str]): self._description = value @property @@ -187,7 +274,7 @@ def revisions(self) -> list[RevisionItem]: def size(self) -> Optional[int]: return self._size - def _set_connections(self, connections): + def _set_connections(self, connections) -> None: self._connections = connections def _set_permissions(self, permissions): diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 5a48f3c93..e50a74ecb 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -6,10 +6,11 @@ from contextlib import closing from pathlib import Path -from typing import Optional, TYPE_CHECKING, Union +from typing import Literal, Optional, TYPE_CHECKING, Union, overload from collections.abc import Iterable, Mapping, Sequence from tableauserverclient.helpers.headers import fix_filename +from tableauserverclient.models.dqw_item import DQWItem from tableauserverclient.server.query import QuerySet if TYPE_CHECKING: @@ -71,6 +72,28 @@ def baseurl(self) -> str: # Get all datasources @api(version="2.0") def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[DatasourceItem], PaginationItem]: + """ + Returns a list of published data sources on the specified site, with + optional parameters for specifying the paging of large results. To get + a list of data sources embedded in a workbook, use the Query Workbook + Connections method. + + Endpoint is paginated, and will return one page per call. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#query_data_sources + + Parameters + ---------- + req_options : Optional[RequestOptions] + Optional parameters for the request, such as filters, sorting, page + size, and page number. + + Returns + ------- + tuple[list[DatasourceItem], PaginationItem] + A tuple containing the list of datasource items and pagination + information. + """ logger.info("Querying all datasources on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -81,6 +104,21 @@ def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[Dataso # Get 1 datasource by id @api(version="2.0") def get_by_id(self, datasource_id: str) -> DatasourceItem: + """ + Returns information about a specific data source. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#query_data_source + + Parameters + ---------- + datasource_id : str + The unique ID of the datasource to retrieve. + + Returns + ------- + DatasourceItem + An object containing information about the datasource. + """ if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) @@ -92,6 +130,20 @@ def get_by_id(self, datasource_id: str) -> DatasourceItem: # Populate datasource item's connections @api(version="2.0") def populate_connections(self, datasource_item: DatasourceItem) -> None: + """ + Retrieve connection information for the specificed datasource item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#query_data_source_connections + + Parameters + ---------- + datasource_item : DatasourceItem + The datasource item to retrieve connections for. + + Returns + ------- + None + """ if not datasource_item.id: error = "Datasource item missing ID. Datasource must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -116,6 +168,22 @@ def _get_datasource_connections( # Delete 1 datasource by id @api(version="2.0") def delete(self, datasource_id: str) -> None: + """ + Deletes the specified data source from a site. When a data source is + deleted, its associated data connection is also deleted. Workbooks that + use the data source are not deleted, but they will no longer work + properly. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#delete_data_source + + Parameters + ---------- + datasource_id : str + + Returns + ------- + None + """ if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) @@ -133,6 +201,29 @@ def download( filepath: Optional[PathOrFileW] = None, include_extract: bool = True, ) -> PathOrFileW: + """ + Downloads the specified data source from a site. The data source is + downloaded as a .tdsx file. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#download_data_source + + Parameters + ---------- + datasource_id : str + The unique ID of the datasource to download. + + filepath : Optional[PathOrFileW] + The file path to save the downloaded datasource to. If not + specified, the file will be saved to the current working directory. + + include_extract : bool, default True + If True, the extract is included in the download. If False, the + extract is not included. + + Returns + ------- + filepath : PathOrFileW + """ return self.download_revision( datasource_id, None, @@ -143,6 +234,28 @@ def download( # Update datasource @api(version="2.0") def update(self, datasource_item: DatasourceItem) -> DatasourceItem: + """ + Updates the owner, project or certification status of the specified + data source. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#update_data_source + + Parameters + ---------- + datasource_item : DatasourceItem + The datasource item to update. + + Returns + ------- + DatasourceItem + An object containing information about the updated datasource. + + Raises + ------ + MissingRequiredFieldError + If the datasource item is missing an ID. + """ + if not datasource_item.id: error = "Datasource item missing ID. Datasource must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -171,6 +284,26 @@ def update(self, datasource_item: DatasourceItem) -> DatasourceItem: def update_connection( self, datasource_item: DatasourceItem, connection_item: ConnectionItem ) -> Optional[ConnectionItem]: + """ + Updates the server address, port, username, or password for the + specified data source connection. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#update_data_source_connection + + Parameters + ---------- + datasource_item : DatasourceItem + The datasource item to update. + + connection_item : ConnectionItem + The connection item to update. + + Returns + ------- + Optional[ConnectionItem] + An object containing information about the updated connection. + """ + url = f"{self.baseurl}/{datasource_item.id}/connections/{connection_item.id}" update_req = RequestFactory.Connection.update_req(connection_item) @@ -188,6 +321,23 @@ def update_connection( @api(version="2.8") def refresh(self, datasource_item: DatasourceItem, incremental: bool = False) -> JobItem: + """ + Refreshes the extract of an existing workbook. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#run_extract_refresh_task + + Parameters + ---------- + workbook_item : WorkbookItem | str + The workbook item or workbook ID. + incremental: bool + Whether to do a full refresh or incremental refresh of the extract data + + Returns + ------- + JobItem + The job item. + """ id_ = getattr(datasource_item, "id", datasource_item) url = f"{self.baseurl}/{id_}/refresh" # refresh_req = RequestFactory.Task.refresh_req(incremental) @@ -198,6 +348,25 @@ def refresh(self, datasource_item: DatasourceItem, incremental: bool = False) -> @api(version="3.5") def create_extract(self, datasource_item: DatasourceItem, encrypt: bool = False) -> JobItem: + """ + Create an extract for a data source in a site. Optionally, encrypt the + extract if the site and workbooks using it are configured to allow it. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#create_extract_for_datasource + + Parameters + ---------- + datasource_item : DatasourceItem | str + The datasource item or datasource ID. + + encrypt : bool, default False + Whether to encrypt the extract. + + Returns + ------- + JobItem + The job item. + """ id_ = getattr(datasource_item, "id", datasource_item) url = f"{self.baseurl}/{id_}/createExtract?encrypt={encrypt}" empty_req = RequestFactory.Empty.empty_req() @@ -207,11 +376,49 @@ def create_extract(self, datasource_item: DatasourceItem, encrypt: bool = False) @api(version="3.5") def delete_extract(self, datasource_item: DatasourceItem) -> None: + """ + Delete the extract of a data source in a site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#delete_extract_from_datasource + + Parameters + ---------- + datasource_item : DatasourceItem | str + The datasource item or datasource ID. + + Returns + ------- + None + """ id_ = getattr(datasource_item, "id", datasource_item) url = f"{self.baseurl}/{id_}/deleteExtract" empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) + @overload + def publish( + self, + datasource_item: DatasourceItem, + file: PathOrFileR, + mode: str, + connection_credentials: Optional[ConnectionCredentials] = None, + connections: Optional[Sequence[ConnectionItem]] = None, + as_job: Literal[False] = False, + ) -> DatasourceItem: + pass + + @overload + def publish( + self, + datasource_item: DatasourceItem, + file: PathOrFileR, + mode: str, + connection_credentials: Optional[ConnectionCredentials] = None, + connections: Optional[Sequence[ConnectionItem]] = None, + as_job: Literal[True] = True, + ) -> JobItem: + pass + # Publish datasource @api(version="2.0") @parameter_added_in(connections="2.8") @@ -225,6 +432,50 @@ def publish( connections: Optional[Sequence[ConnectionItem]] = None, as_job: bool = False, ) -> Union[DatasourceItem, JobItem]: + """ + Publishes a data source to a server, or appends data to an existing + data source. + + This method checks the size of the data source and automatically + determines whether the publish the data source in multiple parts or in + one operation. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#publish_data_source + + Parameters + ---------- + datasource_item : DatasourceItem + The datasource item to publish. The fields for name and project_id + are required. + + file : PathOrFileR + The file path or file object to publish. + + mode : str + Specifies whether you are publishing a new datasource (CreateNew), + overwriting an existing datasource (Overwrite), or add to an + existing datasource (Append). You can also use the publish mode + attributes, for example: TSC.Server.PublishMode.Overwrite. + + connection_credentials : Optional[ConnectionCredentials] + The connection credentials to use when publishing the datasource. + Mutually exclusive with the connections parameter. + + connections : Optional[Sequence[ConnectionItem]] + The connections to use when publishing the datasource. Mutually + exclusive with the connection_credentials parameter. + + as_job : bool, default False + If True, the publish operation is asynchronous and returns a job + item. If False, the publish operation is synchronous and returns a + datasource item. + + Returns + ------- + Union[DatasourceItem, JobItem] + The datasource item or job item. + + """ if isinstance(file, (os.PathLike, str)): if not os.path.isfile(file): error = "File path does not lead to an existing file." @@ -329,6 +580,51 @@ def update_hyper_data( actions: Sequence[Mapping], payload: Optional[FilePath] = None, ) -> JobItem: + """ + Incrementally updates data (insert, update, upsert, replace and delete) + in a published data source from a live-to-Hyper connection, where the + data source has multiple connections. + + A live-to-Hyper connection has a Hyper or Parquet formatted + file/database as the origin of its data. + + For all connections to Parquet files, and for any data sources with a + single connection generally, you can use the Update Data in Hyper Data + Source method without specifying the connection-id. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#update_data_in_hyper_connection + + Parameters + ---------- + datasource_or_connection_item : Union[DatasourceItem, ConnectionItem, str] + The datasource item, connection item, or datasource ID. Either a + DataSourceItem or a ConnectionItem. If the datasource only contains + a single connection, the DataSourceItem is sufficient to identify + which data should be updated. Otherwise, for datasources with + multiple connections, a ConnectionItem must be provided. + + request_id : str + User supplied arbitrary string to identify the request. A request + identified with the same key will only be executed once, even if + additional requests using the key are made, for instance, due to + retries when facing network issues. + + actions : Sequence[Mapping] + A list of actions (insert, update, delete, ...) specifying how to + modify the data within the published datasource. For more + information on the actions, see: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_how_to_update_data_to_hyper.htm#action-batch-descriptions + + payload : Optional[FilePath] + A Hyper file containing tuples to be inserted/deleted/updated or + other payload data used by the actions. Hyper files can be created + using the Tableau Hyper API or pantab. + + Returns + ------- + JobItem + The job running on the server. + + """ if isinstance(datasource_or_connection_item, DatasourceItem): datasource_id = datasource_or_connection_item.id url = f"{self.baseurl}/{datasource_id}/data" @@ -357,35 +653,179 @@ def update_hyper_data( @api(version="2.0") def populate_permissions(self, item: DatasourceItem) -> None: + """ + Populates the permissions on the specified datasource item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_data_source_permissions + + Parameters + ---------- + item : DatasourceItem + The datasource item to populate permissions for. + + Returns + ------- + None + """ self._permissions.populate(item) @api(version="2.0") def update_permissions(self, item: DatasourceItem, permission_item: list["PermissionsRule"]) -> None: + """ + Updates the permissions on the specified datasource item. This method + overwrites all existing permissions. Any permissions not included in + the list will be removed. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_permissions_for_content + + Parameters + ---------- + item : DatasourceItem + The datasource item to update permissions for. + + permission_item : list[PermissionsRule] + The permissions to apply to the datasource item. + + Returns + ------- + None + """ self._permissions.update(item, permission_item) @api(version="2.0") def delete_permission(self, item: DatasourceItem, capability_item: "PermissionsRule") -> None: + """ + Deletes a single permission rule from the specified datasource item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_data_source_permissionDatasourceItem + + Parameters + ---------- + item : DatasourceItem + The datasource item to delete permissions from. + + capability_item : PermissionsRule + The permission rule to delete. + + Returns + ------- + None + """ self._permissions.delete(item, capability_item) @api(version="3.5") - def populate_dqw(self, item): + def populate_dqw(self, item) -> None: + """ + Get information about the data quality warning for the database, table, + column, published data source, flow, virtual connection, or virtual + connection table. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_dqws + + Parameters + ---------- + item : DatasourceItem + The datasource item to populate data quality warnings for. + + Returns + ------- + None + """ self._data_quality_warnings.populate(item) @api(version="3.5") def update_dqw(self, item, warning): + """ + Update the warning type, status, and message of a data quality warning. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#update_dqw + + Parameters + ---------- + item : DatasourceItem + The datasource item to update data quality warnings for. + + warning : DQWItem + The data quality warning to update. + + Returns + ------- + DQWItem + The updated data quality warning. + """ return self._data_quality_warnings.update(item, warning) @api(version="3.5") def add_dqw(self, item, warning): + """ + Add a data quality warning to a datasource. + + The Add Data Quality Warning method adds a data quality warning to an + asset. (An automatically-generated monitoring warning does not count + towards this limit.) In Tableau Cloud February 2024 and Tableau Server + 2024.2 and earlier, adding a data quality warning to an asset that + already has one causes an error. + + This method is available if your Tableau Cloud site or Tableau Server is licensed with Data Management. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#add_dqw + + Parameters + ---------- + item: DatasourceItem + The datasource item to add data quality warnings to. + + warning: DQWItem + The data quality warning to add. + + Returns + ------- + DQWItem + The added data quality warning. + + """ return self._data_quality_warnings.add(item, warning) @api(version="3.5") def delete_dqw(self, item): + """ + Delete a data quality warnings from an asset. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#delete_dqws + + Parameters + ---------- + item: DatasourceItem + The datasource item to delete data quality warnings from. + + Returns + ------- + None + """ self._data_quality_warnings.clear(item) # Populate datasource item's revisions @api(version="2.3") def populate_revisions(self, datasource_item: DatasourceItem) -> None: + """ + Retrieve revision information for the specified datasource item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#get_data_source_revisions + + Parameters + ---------- + datasource_item : DatasourceItem + The datasource item to retrieve revisions for. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the datasource item is missing an ID. + """ if not datasource_item.id: error = "Datasource item missing ID. Datasource must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -413,6 +853,35 @@ def download_revision( filepath: Optional[PathOrFileW] = None, include_extract: bool = True, ) -> PathOrFileW: + """ + Downloads a specific version of a data source prior to the current one + in .tdsx format. To download the current version of a data source set + the revision number to None. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#download_data_source_revision + + Parameters + ---------- + datasource_id : str + The unique ID of the datasource to download. + + revision_number : Optional[str] + The revision number of the data source to download. To determine + what versions are available, call the `populate_revisions` method. + Pass None to download the current version. + + filepath : Optional[PathOrFileW] + The file path to save the downloaded datasource to. If not + specified, the file will be saved to the current working directory. + + include_extract : bool, default True + If True, the extract is included in the download. If False, the + extract is not included. + + Returns + ------- + filepath : PathOrFileW + """ if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) @@ -446,6 +915,28 @@ def download_revision( @api(version="2.3") def delete_revision(self, datasource_id: str, revision_number: str) -> None: + """ + Removes a specific version of a data source from the specified site. + + The content is removed, and the specified revision can no longer be + downloaded using Download Data Source Revision. If you call Get Data + Source Revisions, the revision that's been removed is listed with the + attribute is_deleted=True. + + REST API:https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#remove_data_source_revision + + Parameters + ---------- + datasource_id : str + The unique ID of the datasource to delete. + + revision_number : str + The revision number of the data source to delete. + + Returns + ------- + None + """ if datasource_id is None or revision_number is None: raise ValueError url = "/".join([self.baseurl, datasource_id, "revisions", revision_number]) @@ -458,18 +949,83 @@ def delete_revision(self, datasource_id: str, revision_number: str) -> None: def schedule_extract_refresh( self, schedule_id: str, item: DatasourceItem ) -> list["AddResponse"]: # actually should return a task + """ + Adds a task to refresh a data source to an existing server schedule on + Tableau Server. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#add_data_source_to_schedule + + Parameters + ---------- + schedule_id : str + The unique ID of the schedule to add the task to. + + item : DatasourceItem + The datasource item to add to the schedule. + + Returns + ------- + list[AddResponse] + """ return self.parent_srv.schedules.add_to_schedule(schedule_id, datasource=item) @api(version="1.0") def add_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> set[str]: + """ + Adds one or more tags to the specified datasource item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#add_tags_to_data_source + + Parameters + ---------- + item : Union[DatasourceItem, str] + The datasource item or ID to add tags to. + + tags : Union[Iterable[str], str] + The tag or tags to add to the datasource item. + + Returns + ------- + set[str] + The updated set of tags on the datasource item. + """ return super().add_tags(item, tags) @api(version="1.0") def delete_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> None: + """ + Deletes one or more tags from the specified datasource item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#delete_tag_from_data_source + + Parameters + ---------- + item : Union[DatasourceItem, str] + The datasource item or ID to delete tags from. + + tags : Union[Iterable[str], str] + The tag or tags to delete from the datasource item. + + Returns + ------- + None + """ return super().delete_tags(item, tags) @api(version="1.0") def update_tags(self, item: DatasourceItem) -> None: + """ + Updates the tags on the server to match the specified datasource item. + + Parameters + ---------- + item : DatasourceItem + The datasource item to update tags for. + + Returns + ------- + None + """ return super().update_tags(item) def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[DatasourceItem]: From f17e8e77af78f49e2e7ac2472571d0d39599ff25 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 20 Mar 2025 17:21:12 -0500 Subject: [PATCH 283/296] fix: change GroupSets.get api for more consistency (#1568) All other endpoints accept RequestOptions as an argument named req_options. This PR makes GroupSets more consistent with other endpoints. This makes it work with the Pager option now. Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/server/endpoint/groupsets_endpoint.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/groupsets_endpoint.py b/tableauserverclient/server/endpoint/groupsets_endpoint.py index c7f5ed0e5..8c0ef64f3 100644 --- a/tableauserverclient/server/endpoint/groupsets_endpoint.py +++ b/tableauserverclient/server/endpoint/groupsets_endpoint.py @@ -25,14 +25,14 @@ def baseurl(self) -> str: @api(version="3.22") def get( self, - request_options: Optional[RequestOptions] = None, + req_options: Optional[RequestOptions] = None, result_level: Optional[Literal["members", "local"]] = None, ) -> tuple[list[GroupSetItem], PaginationItem]: logger.info("Querying all group sets on site") url = self.baseurl if result_level: url += f"?resultlevel={result_level}" - server_response = self.get_request(url, request_options) + server_response = self.get_request(url, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) all_group_set_items = GroupSetItem.from_response(server_response.content, self.parent_srv.namespace) return all_group_set_items, pagination_item From e39dbcb3fecfaf3f8191c7fdbc32de838ad6968e Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 20 Mar 2025 17:21:38 -0500 Subject: [PATCH 284/296] fix: virtual connection ConnectionItem attributes (#1566) Closes #1558 Connection XML element for VirtualConnections has different attribute keys compared to connection XML elements when returned by Datasources, Workbooks, and Flows. This PR adds in flexibility to ConnectionItem's reading of XML to account for both sets of attributes that may be present elements. Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/connection_item.py | 6 +-- ...rtual_connection_populate_connections2.xml | 6 +++ test/test_virtual_connection.py | 40 +++++++++++-------- 3 files changed, 32 insertions(+), 20 deletions(-) create mode 100644 test/assets/virtual_connection_populate_connections2.xml diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index e68958c3b..6a8244fb1 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -103,11 +103,11 @@ def from_response(cls, resp, ns) -> list["ConnectionItem"]: all_connection_xml = parsed_response.findall(".//t:connection", namespaces=ns) for connection_xml in all_connection_xml: connection_item = cls() - connection_item._id = connection_xml.get("id", None) + connection_item._id = connection_xml.get("id", connection_xml.get("connectionId", None)) connection_item._connection_type = connection_xml.get("type", connection_xml.get("dbClass", None)) connection_item.embed_password = string_to_bool(connection_xml.get("embedPassword", "")) - connection_item.server_address = connection_xml.get("serverAddress", None) - connection_item.server_port = connection_xml.get("serverPort", None) + connection_item.server_address = connection_xml.get("serverAddress", connection_xml.get("server", None)) + connection_item.server_port = connection_xml.get("serverPort", connection_xml.get("port", None)) connection_item.username = connection_xml.get("userName", None) connection_item._query_tagging = ( string_to_bool(s) if (s := connection_xml.get("queryTagging", None)) else None diff --git a/test/assets/virtual_connection_populate_connections2.xml b/test/assets/virtual_connection_populate_connections2.xml new file mode 100644 index 000000000..f0ad2646d --- /dev/null +++ b/test/assets/virtual_connection_populate_connections2.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/test_virtual_connection.py b/test/test_virtual_connection.py index 975033d2d..5d9a2d1bc 100644 --- a/test/test_virtual_connection.py +++ b/test/test_virtual_connection.py @@ -2,6 +2,7 @@ from pathlib import Path import unittest +import pytest import requests_mock import tableauserverclient as TSC @@ -12,6 +13,7 @@ VIRTUAL_CONNECTION_GET_XML = ASSET_DIR / "virtual_connections_get.xml" VIRTUAL_CONNECTION_POPULATE_CONNECTIONS = ASSET_DIR / "virtual_connection_populate_connections.xml" +VIRTUAL_CONNECTION_POPULATE_CONNECTIONS2 = ASSET_DIR / "virtual_connection_populate_connections2.xml" VC_DB_CONN_UPDATE = ASSET_DIR / "virtual_connection_database_connection_update.xml" VIRTUAL_CONNECTION_DOWNLOAD = ASSET_DIR / "virtual_connections_download.xml" VIRTUAL_CONNECTION_UPDATE = ASSET_DIR / "virtual_connections_update.xml" @@ -54,23 +56,27 @@ def test_virtual_connection_get(self): assert items[0].name == "vconn" def test_virtual_connection_populate_connections(self): - vconn = VirtualConnectionItem("vconn") - vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" - with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{vconn.id}/connections", text=VIRTUAL_CONNECTION_POPULATE_CONNECTIONS.read_text()) - vc_out = self.server.virtual_connections.populate_connections(vconn) - connection_list = list(vconn.connections) - - assert vc_out is vconn - assert vc_out._connections is not None - - assert len(connection_list) == 1 - connection = connection_list[0] - assert connection.id == "37ca6ced-58d7-4dcf-99dc-f0a85223cbef" - assert connection.connection_type == "postgres" - assert connection.server_address == "localhost" - assert connection.server_port == "5432" - assert connection.username == "pgadmin" + for i, populate_connections_xml in enumerate( + (VIRTUAL_CONNECTION_POPULATE_CONNECTIONS, VIRTUAL_CONNECTION_POPULATE_CONNECTIONS2) + ): + with self.subTest(i): + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{vconn.id}/connections", text=populate_connections_xml.read_text()) + vc_out = self.server.virtual_connections.populate_connections(vconn) + connection_list = list(vconn.connections) + + assert vc_out is vconn + assert vc_out._connections is not None + + assert len(connection_list) == 1 + connection = connection_list[0] + assert connection.id == "37ca6ced-58d7-4dcf-99dc-f0a85223cbef" + assert connection.connection_type == "postgres" + assert connection.server_address == "localhost" + assert connection.server_port == "5432" + assert connection.username == "pgadmin" def test_virtual_connection_update_connection_db_connection(self): vconn = VirtualConnectionItem("vconn") From ba716b9d5d08977306a3d463f2c07e32077df02e Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 20 Mar 2025 17:21:52 -0500 Subject: [PATCH 285/296] fix: remove vizHeight and vizWidth from ImageRequestOptions (#1565) feat: properly support request option filters Many filters and RequestOptions added in 3.23. Adds explicit support for them and checks for prior versions. Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/__init__.py | 2 + tableauserverclient/server/__init__.py | 2 + .../server/endpoint/exceptions.py | 4 ++ .../server/endpoint/views_endpoint.py | 6 ++- .../server/endpoint/workbooks_endpoint.py | 32 ++++++++---- tableauserverclient/server/request_options.py | 34 +++++++++++++ test/test_view.py | 38 ++++++++++++++ test/test_workbook.py | 51 +++++++++++++++++-- 8 files changed, 156 insertions(+), 13 deletions(-) diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 39f8267a8..957a820db 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -56,6 +56,7 @@ ExcelRequestOptions, ImageRequestOptions, PDFRequestOptions, + PPTXRequestOptions, RequestOptions, MissingRequiredFieldError, FailedSignInError, @@ -107,6 +108,7 @@ "Pager", "PaginationItem", "PDFRequestOptions", + "PPTXRequestOptions", "Permission", "PermissionsRule", "PersonalAccessTokenAuth", diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index 87cc9460b..55288fdc9 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -5,6 +5,7 @@ ExcelRequestOptions, ImageRequestOptions, PDFRequestOptions, + PPTXRequestOptions, RequestOptions, ) from tableauserverclient.server.filter import Filter @@ -52,6 +53,7 @@ "ExcelRequestOptions", "ImageRequestOptions", "PDFRequestOptions", + "PPTXRequestOptions", "RequestOptions", "Filter", "Sort", diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 77332da3e..ee931c910 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -113,3 +113,7 @@ def __str__(self): class FlowRunCancelledException(FlowRunFailedException): pass + + +class UnsupportedAttributeError(TableauError): + pass diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 12b386876..9d1c8b00f 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -3,7 +3,7 @@ from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api -from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError +from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError, UnsupportedAttributeError from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin from tableauserverclient.server.query import QuerySet @@ -171,6 +171,10 @@ def populate_image(self, view_item: ViewItem, req_options: Optional["ImageReques def image_fetcher(): return self._get_view_image(view_item, req_options) + if not self.parent_srv.check_at_least_version("3.23") and req_options is not None: + if req_options.viz_height or req_options.viz_width: + raise UnsupportedAttributeError("viz_height and viz_width are only supported in 3.23+") + view_item._set_image(image_fetcher) logger.info(f"Populated image for view (ID: {view_item.id})") diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 4fdcf075b..8507152ba 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -11,7 +11,11 @@ from tableauserverclient.server.query import QuerySet from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api, parameter_added_in -from tableauserverclient.server.endpoint.exceptions import InternalServerError, MissingRequiredFieldError +from tableauserverclient.server.endpoint.exceptions import ( + InternalServerError, + MissingRequiredFieldError, + UnsupportedAttributeError, +) from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin @@ -34,7 +38,7 @@ if TYPE_CHECKING: from tableauserverclient.server import Server - from tableauserverclient.server.request_options import RequestOptions + from tableauserverclient.server.request_options import RequestOptions, PDFRequestOptions, PPTXRequestOptions from tableauserverclient.models import DatasourceItem from tableauserverclient.server.endpoint.schedules_endpoint import AddResponse @@ -472,11 +476,12 @@ def _get_workbook_connections( connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) return connections - # Get the pdf of the entire workbook if its tabs are enabled, pdf of the default view if its tabs are disabled @api(version="3.4") - def populate_pdf(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None) -> None: + def populate_pdf(self, workbook_item: WorkbookItem, req_options: Optional["PDFRequestOptions"] = None) -> None: """ - Populates the PDF for the specified workbook item. + Populates the PDF for the specified workbook item. Get the pdf of the + entire workbook if its tabs are enabled, pdf of the default view if its + tabs are disabled. This method populates a PDF with image(s) of the workbook view(s) you specify. @@ -488,7 +493,7 @@ def populate_pdf(self, workbook_item: WorkbookItem, req_options: Optional["Reque workbook_item : WorkbookItem The workbook item to populate the PDF for. - req_options : RequestOptions, optional + req_options : PDFRequestOptions, optional (Optional) You can pass in request options to specify the page type and orientation of the PDF content, as well as the maximum age of the PDF rendered on the server. See PDFRequestOptions class for more @@ -510,17 +515,26 @@ def populate_pdf(self, workbook_item: WorkbookItem, req_options: Optional["Reque def pdf_fetcher() -> bytes: return self._get_wb_pdf(workbook_item, req_options) + if not self.parent_srv.check_at_least_version("3.23") and req_options is not None: + if req_options.view_filters or req_options.view_parameters: + raise UnsupportedAttributeError("view_filters and view_parameters are only supported in 3.23+") + + if req_options.viz_height or req_options.viz_width: + raise UnsupportedAttributeError("viz_height and viz_width are only supported in 3.23+") + workbook_item._set_pdf(pdf_fetcher) logger.info(f"Populated pdf for workbook (ID: {workbook_item.id})") - def _get_wb_pdf(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"]) -> bytes: + def _get_wb_pdf(self, workbook_item: WorkbookItem, req_options: Optional["PDFRequestOptions"]) -> bytes: url = f"{self.baseurl}/{workbook_item.id}/pdf" server_response = self.get_request(url, req_options) pdf = server_response.content return pdf @api(version="3.8") - def populate_powerpoint(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None) -> None: + def populate_powerpoint( + self, workbook_item: WorkbookItem, req_options: Optional["PPTXRequestOptions"] = None + ) -> None: """ Populates the PowerPoint for the specified workbook item. @@ -561,7 +575,7 @@ def pptx_fetcher() -> bytes: workbook_item._set_powerpoint(pptx_fetcher) logger.info(f"Populated powerpoint for workbook (ID: {workbook_item.id})") - def _get_wb_pptx(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"]) -> bytes: + def _get_wb_pptx(self, workbook_item: WorkbookItem, req_options: Optional["PPTXRequestOptions"]) -> bytes: url = f"{self.baseurl}/{workbook_item.id}/powerpoint" server_response = self.get_request(url, req_options) pptx = server_response.content diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index c37c0ce42..504f7f3ca 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -385,6 +385,8 @@ class PDFRequestOptions(_ImagePDFCommonExportOptions): Options that can be used when exporting a view to PDF. Set the maxage to control the age of the data exported. Filters to the underlying data can be applied using the `vf` and `parameter` methods. + vf and parameter filters are only supported in API version 3.23 and later. + Parameters ---------- page_type: str, optional @@ -438,3 +440,35 @@ def get_query_params(self) -> dict: params["orientation"] = self.orientation return params + + +class PPTXRequestOptions(RequestOptionsBase): + """ + Options that can be used when exporting a view to PPTX. Set the maxage to control the age of the data exported. + + Parameters + ---------- + maxage: int, optional + The maximum age of the data to export. Shortest possible duration is 1 + minute. No upper limit. Default is -1, which means no limit. + """ + + def __init__(self, maxage=-1): + super().__init__() + self.max_age = maxage + + @property + def max_age(self) -> int: + return self._max_age + + @max_age.setter + @property_is_int(range=(0, 240), allowed=[-1]) + def max_age(self, value): + self._max_age = value + + def get_query_params(self): + params = {} + if self.max_age != -1: + params["maxAge"] = self.max_age + + return params diff --git a/test/test_view.py b/test/test_view.py index a89a6d235..3fdaf60e6 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -6,6 +6,7 @@ import tableauserverclient as TSC from tableauserverclient import UserItem, GroupItem, PermissionsRule from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.server.endpoint.exceptions import UnsupportedAttributeError TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -177,6 +178,43 @@ def test_populate_image(self) -> None: self.server.views.populate_image(single_view) self.assertEqual(response, single_view.image) + def test_populate_image_unsupported(self) -> None: + self.server.version = "3.8" + with open(POPULATE_PREVIEW_IMAGE, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get( + self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?vizWidth=1920&vizHeight=1080", + content=response, + ) + single_view = TSC.ViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + + req_option = TSC.ImageRequestOptions(viz_width=1920, viz_height=1080) + + with self.assertRaises(UnsupportedAttributeError): + self.server.views.populate_image(single_view, req_option) + + def test_populate_image_viz_dimensions(self) -> None: + self.server.version = "3.23" + self.baseurl = self.server.views.baseurl + with open(POPULATE_PREVIEW_IMAGE, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get( + self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?vizWidth=1920&vizHeight=1080", + content=response, + ) + single_view = TSC.ViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + + req_option = TSC.ImageRequestOptions(viz_width=1920, viz_height=1080) + + self.server.views.populate_image(single_view, req_option) + self.assertEqual(response, single_view.image) + + history = m.request_history + def test_populate_image_with_options(self) -> None: with open(POPULATE_PREVIEW_IMAGE, "rb") as f: response = f.read() diff --git a/test/test_workbook.py b/test/test_workbook.py index 0aa52f50d..f3c2dd147 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -12,7 +12,7 @@ import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime from tableauserverclient.models import UserItem, GroupItem, PermissionsRule -from tableauserverclient.server.endpoint.exceptions import InternalServerError +from tableauserverclient.server.endpoint.exceptions import InternalServerError, UnsupportedAttributeError from tableauserverclient.server.request_factory import RequestFactory from ._utils import asset @@ -450,6 +450,49 @@ def test_populate_pdf(self) -> None: self.server.workbooks.populate_pdf(single_workbook, req_option) self.assertEqual(response, single_workbook.pdf) + def test_populate_pdf_unsupported(self) -> None: + self.server.version = "3.4" + self.baseurl = self.server.workbooks.baseurl + with requests_mock.mock() as m: + m.get( + self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/pdf?type=a5&orientation=landscape", + content=b"", + ) + single_workbook = TSC.WorkbookItem("test") + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + + type = TSC.PDFRequestOptions.PageType.A5 + orientation = TSC.PDFRequestOptions.Orientation.Landscape + req_option = TSC.PDFRequestOptions(type, orientation) + req_option.vf("Region", "West") + + with self.assertRaises(UnsupportedAttributeError): + self.server.workbooks.populate_pdf(single_workbook, req_option) + + def test_populate_pdf_vf_dims(self) -> None: + self.server.version = "3.23" + self.baseurl = self.server.workbooks.baseurl + with open(POPULATE_PDF, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get( + self.baseurl + + "/1f951daf-4061-451a-9df1-69a8062664f2/pdf?type=a5&orientation=landscape&vf_Region=West&vizWidth=1920&vizHeight=1080", + content=response, + ) + single_workbook = TSC.WorkbookItem("test") + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + + type = TSC.PDFRequestOptions.PageType.A5 + orientation = TSC.PDFRequestOptions.Orientation.Landscape + req_option = TSC.PDFRequestOptions(type, orientation) + req_option.vf("Region", "West") + req_option.viz_width = 1920 + req_option.viz_height = 1080 + + self.server.workbooks.populate_pdf(single_workbook, req_option) + self.assertEqual(response, single_workbook.pdf) + def test_populate_powerpoint(self) -> None: self.server.version = "3.8" self.baseurl = self.server.workbooks.baseurl @@ -457,13 +500,15 @@ def test_populate_powerpoint(self) -> None: response = f.read() with requests_mock.mock() as m: m.get( - self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/powerpoint", + self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/powerpoint?maxAge=1", content=response, ) single_workbook = TSC.WorkbookItem("test") single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" - self.server.workbooks.populate_powerpoint(single_workbook) + ro = TSC.PPTXRequestOptions(maxage=1) + + self.server.workbooks.populate_powerpoint(single_workbook, ro) self.assertEqual(response, single_workbook.powerpoint) def test_populate_preview_image(self) -> None: From b81993a1675c140685920b917cd0d00f84fe8873 Mon Sep 17 00:00:00 2001 From: renoyjohnm <168143499+renoyjohnm@users.noreply.github.com> Date: Fri, 21 Mar 2025 14:48:54 -0700 Subject: [PATCH 286/296] Adding incremental refresh option for workbook and datasource endpoints along with the new JobItem code changes (#1585) * Adding incremental refresh option to workbook and datasource along with new job item finish code * Fix build pipeline failures related to mypy & black --- tableauserverclient/models/job_item.py | 1 + .../server/endpoint/datasources_endpoint.py | 3 +- .../server/endpoint/jobs_endpoint.py | 2 +- .../server/endpoint/workbooks_endpoint.py | 2 +- tableauserverclient/server/request_factory.py | 16 +++++-- test/assets/job_get_by_id_completed.xml | 14 ++++++ test/request_factory/test_task_requests.py | 48 +++++++++++++++++++ test/test_job.py | 12 +++++ 8 files changed, 89 insertions(+), 9 deletions(-) create mode 100644 test/assets/job_get_by_id_completed.xml create mode 100644 test/request_factory/test_task_requests.py diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 6286275c5..d650eb846 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -82,6 +82,7 @@ class FinishCode: Success: int = 0 Failed: int = 1 Cancelled: int = 2 + Completed: int = 3 def __init__( self, diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index e50a74ecb..69913a724 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -340,8 +340,7 @@ def refresh(self, datasource_item: DatasourceItem, incremental: bool = False) -> """ id_ = getattr(datasource_item, "id", datasource_item) url = f"{self.baseurl}/{id_}/refresh" - # refresh_req = RequestFactory.Task.refresh_req(incremental) - refresh_req = RequestFactory.Empty.empty_req() + refresh_req = RequestFactory.Task.refresh_req(incremental, self.parent_srv) server_response = self.post_request(url, refresh_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index 027a7ca12..48e91bd74 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -188,7 +188,7 @@ def wait_for_job(self, job_id: Union[str, JobItem], *, timeout: Optional[float] logger.info(f"Job {job_id} Completed: Finish Code: {job.finish_code} - Notes:{job.notes}") - if job.finish_code == JobItem.FinishCode.Success: + if job.finish_code in [JobItem.FinishCode.Success, JobItem.FinishCode.Completed]: return job elif job.finish_code == JobItem.FinishCode.Failed: raise JobFailedException(job) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 8507152ba..bf4088b9f 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -140,7 +140,7 @@ def refresh(self, workbook_item: Union[WorkbookItem, str], incremental: bool = F """ id_ = getattr(workbook_item, "id", workbook_item) url = f"{self.baseurl}/{id_}/refresh" - refresh_req = RequestFactory.Task.refresh_req(incremental) + refresh_req = RequestFactory.Task.refresh_req(incremental, self.parent_srv) server_response = self.post_request(url, refresh_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 79ac6e4ca..575423612 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1118,11 +1118,17 @@ def run_req(self, xml_request: ET.Element, task_item: Any) -> None: pass @_tsrequest_wrapped - def refresh_req(self, xml_request: ET.Element, incremental: bool = False) -> bytes: - task_element = ET.SubElement(xml_request, "extractRefresh") - if incremental: - task_element.attrib["incremental"] = "true" - return ET.tostring(xml_request) + def refresh_req( + self, xml_request: ET.Element, incremental: bool = False, parent_srv: Optional["Server"] = None + ) -> Optional[bytes]: + if parent_srv is not None and parent_srv.check_at_least_version("3.25"): + task_element = ET.SubElement(xml_request, "extractRefresh") + if incremental: + task_element.attrib["incremental"] = "true" + return ET.tostring(xml_request) + elif incremental: + raise ValueError("Incremental refresh is only supported in 3.25+") + return None @_tsrequest_wrapped def create_extract_req(self, xml_request: ET.Element, extract_item: "TaskItem") -> bytes: diff --git a/test/assets/job_get_by_id_completed.xml b/test/assets/job_get_by_id_completed.xml new file mode 100644 index 000000000..95ca29b49 --- /dev/null +++ b/test/assets/job_get_by_id_completed.xml @@ -0,0 +1,14 @@ + + + + + Job detail notes + + + More detail + + + \ No newline at end of file diff --git a/test/request_factory/test_task_requests.py b/test/request_factory/test_task_requests.py new file mode 100644 index 000000000..0258b8a93 --- /dev/null +++ b/test/request_factory/test_task_requests.py @@ -0,0 +1,48 @@ +import unittest +import xml.etree.ElementTree as ET +from unittest.mock import Mock +from tableauserverclient.server.request_factory import TaskRequest + + +class TestTaskRequest(unittest.TestCase): + + def setUp(self): + self.task_request = TaskRequest() + self.xml_request = ET.Element("tsRequest") + + def test_refresh_req_default(self): + result = self.task_request.refresh_req() + self.assertEqual(result, ET.tostring(self.xml_request)) + + def test_refresh_req_incremental(self): + with self.assertRaises(ValueError): + self.task_request.refresh_req(incremental=True) + + def test_refresh_req_with_parent_srv_version_3_25(self): + parent_srv = Mock() + parent_srv.check_at_least_version.return_value = True + result = self.task_request.refresh_req(incremental=True, parent_srv=parent_srv) + expected_xml = ET.Element("tsRequest") + task_element = ET.SubElement(expected_xml, "extractRefresh") + task_element.attrib["incremental"] = "true" + self.assertEqual(result, ET.tostring(expected_xml)) + + def test_refresh_req_with_parent_srv_version_3_25_non_incremental(self): + parent_srv = Mock() + parent_srv.check_at_least_version.return_value = True + result = self.task_request.refresh_req(incremental=False, parent_srv=parent_srv) + expected_xml = ET.Element("tsRequest") + ET.SubElement(expected_xml, "extractRefresh") + self.assertEqual(result, ET.tostring(expected_xml)) + + def test_refresh_req_with_parent_srv_version_below_3_25(self): + parent_srv = Mock() + parent_srv.check_at_least_version.return_value = False + with self.assertRaises(ValueError): + self.task_request.refresh_req(incremental=True, parent_srv=parent_srv) + + def test_refresh_req_with_parent_srv_version_below_3_25_non_incremental(self): + parent_srv = Mock() + parent_srv.check_at_least_version.return_value = False + result = self.task_request.refresh_req(incremental=False, parent_srv=parent_srv) + self.assertEqual(result, ET.tostring(self.xml_request)) diff --git a/test/test_job.py b/test/test_job.py index 20b238764..b3d7007aa 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -11,6 +11,7 @@ GET_XML = "job_get.xml" GET_BY_ID_XML = "job_get_by_id.xml" +GET_BY_ID_COMPLETED_XML = "job_get_by_id_completed.xml" GET_BY_ID_FAILED_XML = "job_get_by_id_failed.xml" GET_BY_ID_CANCELLED_XML = "job_get_by_id_cancelled.xml" GET_BY_ID_INPROGRESS_XML = "job_get_by_id_inprogress.xml" @@ -87,6 +88,17 @@ def test_wait_for_job_finished(self) -> None: self.assertEqual(job_id, job.id) self.assertListEqual(job.notes, ["Job detail notes"]) + def test_wait_for_job_completed(self) -> None: + # Waiting for a bridge (cloud) job completion + response_xml = read_xml_asset(GET_BY_ID_COMPLETED_XML) + job_id = "2eef4225-aa0c-41c4-8662-a76d89ed7336" + with mocked_time(), requests_mock.mock() as m: + m.get(f"{self.baseurl}/{job_id}", text=response_xml) + job = self.server.jobs.wait_for_job(job_id) + + self.assertEqual(job_id, job.id) + self.assertListEqual(job.notes, ["Job detail notes"]) + def test_wait_for_job_failed(self) -> None: # Waiting for a failed job raises an exception response_xml = read_xml_asset(GET_BY_ID_FAILED_XML) From d7935704a0bcaac595d26eb8bf4da39ffb1d4fb5 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 21 Apr 2025 23:24:40 -0500 Subject: [PATCH 287/296] ci: update slack action vm version Switch Slack action to use `ubuntu-latest` like our other actions. --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- .github/workflows/slack.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/slack.yml b/.github/workflows/slack.yml index 2ecb0be7f..9afebf25b 100644 --- a/.github/workflows/slack.yml +++ b/.github/workflows/slack.yml @@ -5,7 +5,7 @@ on: [push, pull_request, issues] jobs: slack-notifications: continue-on-error: true - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest name: Sends a message to Slack when a push, a pull request or an issue is made steps: - name: Send message to Slack API From dbd0c0f1f0a76c04f5b36b79f14ccfb15459082c Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 22 Apr 2025 10:00:44 -0500 Subject: [PATCH 288/296] feat: enable retrieving only owned workbooks Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- .../server/endpoint/users_endpoint.py | 25 ++++++++++++++++--- test/test_user.py | 16 ++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index d81907ae9..75c7bd2ed 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -381,10 +381,15 @@ def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[Us # Get workbooks for user @api(version="2.0") - def populate_workbooks(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: + def populate_workbooks( + self, user_item: UserItem, req_options: Optional[RequestOptions] = None, owned_only: bool = False + ) -> None: """ Returns information about the workbooks that the specified user owns - and has Read (view) permissions for. + or has Read (view) permissions for. If owned_only is set to True, + only the workbooks that the user owns are returned. If owned_only is + set to False, all workbooks that the user has Read (view) permissions + for are returned. This method retrieves the workbook information for the specified user. The REST API is designed to return only the information you ask for @@ -402,6 +407,10 @@ def populate_workbooks(self, user_item: UserItem, req_options: Optional[RequestO req_options : Optional[RequestOptions] Optional request options to filter and sort the results. + owned_only : bool, default=False + If True, only the workbooks that the user owns are returned. + If False, all workbooks that the user has Read (view) permissions + Returns ------- None @@ -423,14 +432,22 @@ def populate_workbooks(self, user_item: UserItem, req_options: Optional[RequestO raise MissingRequiredFieldError(error) def wb_pager(): - return Pager(lambda options: self._get_wbs_for_user(user_item, options), req_options) + def func(req_options): + return self._get_wbs_for_user(user_item, req_options, owned_only=owned_only) + + return Pager(func, req_options) user_item._set_workbooks(wb_pager) def _get_wbs_for_user( - self, user_item: UserItem, req_options: Optional[RequestOptions] = None + self, + user_item: UserItem, + req_options: Optional[RequestOptions] = None, + owned_only: bool = False, ) -> tuple[list[WorkbookItem], PaginationItem]: url = f"{self.baseurl}/{user_item.id}/workbooks" + if owned_only: + url += "?ownedBy=true" server_response = self.get_request(url, req_options) logger.info(f"Populated workbooks for user (ID: {user_item.id})") workbook_item = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace) diff --git a/test/test_user.py b/test/test_user.py index a46624845..645adcfd5 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -162,6 +162,22 @@ def test_populate_workbooks(self) -> None: self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", workbook_list[0].owner_id) self.assertEqual({"Safari", "Sample"}, workbook_list[0].tags) + def test_populate_owned_workbooks(self) -> None: + with open(POPULATE_WORKBOOKS_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + # Query parameter ownedBy is case sensitive. + with requests_mock.mock(case_sensitive=True) as m: + m.get(self.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794/workbooks?ownedBy=true", text=response_xml) + single_user = TSC.UserItem("test", "Interactor") + single_user._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + self.server.users.populate_workbooks(single_user, owned_only=True) + list(single_user.workbooks) + + request_history = m.request_history[0] + + assert "ownedBy" in request_history.qs, "ownedBy not in request history" + assert "true" in request_history.qs["ownedBy"], "ownedBy not set to true in request history" + def test_populate_workbooks_missing_id(self) -> None: single_user = TSC.UserItem("test", "Interactor") self.assertRaises(TSC.MissingRequiredFieldError, self.server.users.populate_workbooks, single_user) From a5ea3338086d74e355cd5a3d0b79f3fe4160a4a2 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 13 May 2025 14:24:23 -0700 Subject: [PATCH 289/296] docs: docstrings for schedules and intervals (#1528) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/interval_item.py | 40 ++++++ tableauserverclient/models/schedule_item.py | 57 +++++++++ .../server/endpoint/schedules_endpoint.py | 114 +++++++++++++++++- 3 files changed, 210 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index d7cf891cc..14cec1878 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -2,6 +2,13 @@ class IntervalItem: + """ + This class sets the frequency and start time of the scheduled item. This + class contains the classes for the hourly, daily, weekly, and monthly + intervals. This class mirrors the options you can set using the REST API and + the Tableau Server interface. + """ + class Frequency: Hourly = "Hourly" Daily = "Daily" @@ -26,6 +33,19 @@ class Day: class HourlyInterval: + """ + Runs scheduled item hourly. To set the hourly interval, you create an + instance of the HourlyInterval class and assign the following values: + start_time, end_time, and interval_value. To set the start_time and + end_time, assign the time value using this syntax: start_time=time(hour, minute) + and end_time=time(hour, minute). The hour is specified in 24 hour time. + The interval_value specifies how often the to run the task within the + start and end time. The options are expressed in hours. For example, + interval_value=.25 is every 15 minutes. The values are .25, .5, 1, 2, 4, 6, + 8, 12. Hourly schedules that run more frequently than every 60 minutes must + have start and end times that are on the hour. + """ + def __init__(self, start_time, end_time, interval_value): self.start_time = start_time self.end_time = end_time @@ -109,6 +129,12 @@ def _interval_type_pairs(self): class DailyInterval: + """ + Runs the scheduled item daily. To set the daily interval, you create an + instance of the DailyInterval and assign the start_time. The start time uses + the syntax start_time=time(hour, minute). + """ + def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values @@ -177,6 +203,15 @@ def _interval_type_pairs(self): class WeeklyInterval: + """ + Runs the scheduled item once a week. To set the weekly interval, you create + an instance of the WeeklyInterval and assign the start time and multiple + instances for the interval_value (days of week and start time). The start + time uses the syntax time(hour, minute). The interval_value is the day of + the week, expressed as a IntervalItem. For example + TSC.IntervalItem.Day.Monday for Monday. + """ + def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values @@ -214,6 +249,11 @@ def _interval_type_pairs(self): class MonthlyInterval: + """ + Runs the scheduled item once a month. To set the monthly interval, you + create an instance of the MonthlyInterval and assign the start time and day. + """ + def __init__(self, start_time, interval_value): self.start_time = start_time diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index e39042058..a2118e3d6 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -20,6 +20,63 @@ class ScheduleItem: + """ + Using the TSC library, you can schedule extract refresh or subscription + tasks on Tableau Server. You can also get and update information about the + scheduled tasks, or delete scheduled tasks. + + If you have the identifier of the job, you can use the TSC library to find + out the status of the asynchronous job. + + The schedule properties are defined in the ScheduleItem class. The class + corresponds to the properties for schedules you can access in Tableau + Server or by using the Tableau Server REST API. The Schedule methods are + based upon the endpoints for jobs in the REST API and operate on the JobItem + class. + + Parameters + ---------- + name : str + The name of the schedule. + + priority : int + The priority of the schedule. Lower values represent higher priority, + with 0 indicating the highest priority. + + schedule_type : str + The type of task schedule. See ScheduleItem.Type for the possible values. + + execution_order : str + Specifies how the scheduled tasks should run. The choices are Parallel + which uses all avaiable background processes for a scheduled task, or + Serial, which limits the schedule to one background process. + + interval_item : Interval + Specifies the frequency that the scheduled task should run. The + interval_item is an instance of the IntervalItem class. The + interval_item has properties for frequency (hourly, daily, weekly, + monthly), and what time and date the scheduled item runs. You set this + value by declaring an IntervalItem object that is one of the following: + HourlyInterval, DailyInterval, WeeklyInterval, or MonthlyInterval. + + Attributes + ---------- + created_at : datetime + The date and time the schedule was created. + + end_schedule_at : datetime + The date and time the schedule ends. + + id : str + The unique identifier for the schedule. + + next_run_at : datetime + The date and time the schedule is next run. + + state : str + The state of the schedule. See ScheduleItem.State for the possible values. + """ + class Type: Extract = "Extract" Flow = "Flow" diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index eec4536f9..8693d66cc 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -30,6 +30,23 @@ def siteurl(self) -> str: @api(version="2.3") def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[ScheduleItem], PaginationItem]: + """ + Returns a list of flows, extract, and subscription server schedules on + Tableau Server. For each schedule, the API returns name, frequency, + priority, and other information. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#query_schedules + + Parameters + ---------- + req_options : Optional[RequestOptions] + Filtering and paginating options for request. + + Returns + ------- + Tuple[List[ScheduleItem], PaginationItem] + A tuple of list of ScheduleItem and PaginationItem + """ logger.info("Querying all schedules") url = self.baseurl server_response = self.get_request(url, req_options) @@ -38,7 +55,22 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Sche return all_schedule_items, pagination_item @api(version="3.8") - def get_by_id(self, schedule_id): + def get_by_id(self, schedule_id: str) -> ScheduleItem: + """ + Returns detailed information about the specified server schedule. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#get-schedule + + Parameters + ---------- + schedule_id : str + The ID of the schedule to get information for. + + Returns + ------- + ScheduleItem + The schedule item that corresponds to the given ID. + """ if not schedule_id: error = "No Schedule ID provided" raise ValueError(error) @@ -49,6 +81,20 @@ def get_by_id(self, schedule_id): @api(version="2.3") def delete(self, schedule_id: str) -> None: + """ + Deletes the specified schedule from the server. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#delete_schedule + + Parameters + ---------- + schedule_id : str + The ID of the schedule to delete. + + Returns + ------- + None + """ if not schedule_id: error = "Schedule ID undefined" raise ValueError(error) @@ -58,6 +104,23 @@ def delete(self, schedule_id: str) -> None: @api(version="2.3") def update(self, schedule_item: ScheduleItem) -> ScheduleItem: + """ + Modifies settings for the specified server schedule, including the name, + priority, and frequency details on Tableau Server. For Tableau Cloud, + see the tasks and subscritpions API. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#update_schedule + + Parameters + ---------- + schedule_item : ScheduleItem + The schedule item to update. + + Returns + ------- + ScheduleItem + The updated schedule item. + """ if not schedule_item.id: error = "Schedule item missing ID." raise MissingRequiredFieldError(error) @@ -71,6 +134,20 @@ def update(self, schedule_item: ScheduleItem) -> ScheduleItem: @api(version="2.3") def create(self, schedule_item: ScheduleItem) -> ScheduleItem: + """ + Creates a new server schedule on Tableau Server. For Tableau Cloud, use + the tasks and subscriptions API. + + Parameters + ---------- + schedule_item : ScheduleItem + The schedule item to create. + + Returns + ------- + ScheduleItem + The newly created schedule. + """ if schedule_item.interval_item is None: error = "Interval item must be defined." raise MissingRequiredFieldError(error) @@ -92,6 +169,41 @@ def add_to_schedule( flow: Optional["FlowItem"] = None, task_type: Optional[str] = None, ) -> list[AddResponse]: + """ + Adds a workbook, datasource, or flow to a schedule on Tableau Server. + Only one of workbook, datasource, or flow can be passed in at a time. + + The task type is optional and will default to ExtractRefresh if a + workbook or datasource is passed in, and RunFlow if a flow is passed in. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#add_workbook_to_schedule + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#add_data_source_to_schedule + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#add_flow_task_to_schedule + + Parameters + ---------- + schedule_id : str + The ID of the schedule to add the item to. + + workbook : Optional[WorkbookItem] + The workbook to add to the schedule. + + datasource : Optional[DatasourceItem] + The datasource to add to the schedule. + + flow : Optional[FlowItem] + The flow to add to the schedule. + + task_type : Optional[str] + The type of task to add to the schedule. If not provided, it will + default to ExtractRefresh if a workbook or datasource is passed in, + and RunFlow if a flow is passed in. + + Returns + ------- + list[AddResponse] + A list of responses for each item added to the schedule. + """ # There doesn't seem to be a good reason to allow one item of each type? if workbook and datasource: warnings.warn("Passing in multiple items for add_to_schedule will be deprecated", PendingDeprecationWarning) From a2b1558dd21032ac10c5e6871f62b3c151c89140 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 13 May 2025 16:33:51 -0700 Subject: [PATCH 290/296] Add support for multiple IDPs (jorwoods) Add support for multiple IDPs Fixes #1574 Fixes #1598 --------- Authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/__init__.py | 2 ++ tableauserverclient/models/__init__.py | 3 +- tableauserverclient/models/site_item.py | 28 +++++++++++++++ tableauserverclient/models/user_item.py | 25 ++++++++++++- .../server/endpoint/sites_endpoint.py | 19 +++++++++- tableauserverclient/server/request_factory.py | 5 +++ test/assets/site_auth_configurations.xml | 18 ++++++++++ test/test_site.py | 26 ++++++++++++++ test/test_user.py | 36 +++++++++++++++++++ 9 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 test/assets/site_auth_configurations.xml diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 957a820db..538f85221 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -35,6 +35,7 @@ Resource, RevisionItem, ScheduleItem, + SiteAuthConfiguration, SiteItem, ServerInfoItem, SubscriptionItem, @@ -121,6 +122,7 @@ "ServerInfoItem", "ServerResponseError", "SiteItem", + "SiteAuthConfiguration", "Sort", "SubscriptionItem", "TableauAuth", diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index e4131b720..10c3149f1 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -35,7 +35,7 @@ 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.site_item import SiteItem, SiteAuthConfiguration from tableauserverclient.models.subscription_item import SubscriptionItem from tableauserverclient.models.table_item import TableItem from tableauserverclient.models.tableau_auth import Credentials, TableauAuth, PersonalAccessTokenAuth, JWTAuth @@ -83,6 +83,7 @@ "RevisionItem", "ScheduleItem", "ServerInfoItem", + "SiteAuthConfiguration", "SiteItem", "SubscriptionItem", "TableItem", diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index e4e146f9c..ab65b97b5 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -1188,6 +1188,34 @@ def _parse_element(site_xml, ns): ) +class SiteAuthConfiguration: + """ + Authentication configuration for a site. + """ + + def __init__(self): + self.auth_setting: Optional[str] = None + self.enabled: Optional[bool] = None + self.idp_configuration_id: Optional[str] = None + self.idp_configuration_name: Optional[str] = None + self.known_provider_alias: Optional[str] = None + + @classmethod + def from_response(cls, resp: bytes, ns: dict) -> list["SiteAuthConfiguration"]: + all_auth_configs = list() + parsed_response = fromstring(resp) + all_auth_xml = parsed_response.findall(".//t:siteAuthConfiguration", namespaces=ns) + for auth_xml in all_auth_xml: + auth_config = cls() + auth_config.auth_setting = auth_xml.get("authSetting", None) + auth_config.enabled = string_to_bool(auth_xml.get("enabled", "")) + auth_config.idp_configuration_id = auth_xml.get("idpConfigurationId", None) + auth_config.idp_configuration_name = auth_xml.get("idpConfigurationName", None) + auth_config.known_provider_alias = auth_xml.get("knownProviderAlias", None) + all_auth_configs.append(auth_config) + return all_auth_configs + + # Used to convert string represented boolean to a boolean type def string_to_bool(s: str) -> bool: return s.lower() == "true" diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 365e44c1d..5f6702b80 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -7,6 +7,7 @@ from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime +from tableauserverclient.models.site_item import SiteAuthConfiguration from .exceptions import UnpopulatedPropertyError from .property_decorators import ( property_is_enum, @@ -94,6 +95,7 @@ def __init__( self.name: Optional[str] = name self.site_role: Optional[str] = site_role self.auth_setting: Optional[str] = auth_setting + self._idp_configuration_id: Optional[str] = None return None @@ -184,6 +186,18 @@ def groups(self) -> "Pager": raise UnpopulatedPropertyError(error) return self._groups() + @property + def idp_configuration_id(self) -> Optional[str]: + """ + IDP configuration id for the user. This is only available on Tableau + Cloud, 3.24 or later + """ + return self._idp_configuration_id + + @idp_configuration_id.setter + def idp_configuration_id(self, value: str) -> None: + self._idp_configuration_id = value + def _set_workbooks(self, workbooks) -> None: self._workbooks = workbooks @@ -204,8 +218,9 @@ def _parse_common_tags(self, user_xml, ns) -> "UserItem": email, auth_setting, _, + _, ) = self._parse_element(user_xml, ns) - self._set_values(None, None, site_role, None, None, fullname, email, auth_setting, None) + self._set_values(None, None, site_role, None, None, fullname, email, auth_setting, None, None) return self def _set_values( @@ -219,6 +234,7 @@ def _set_values( email, auth_setting, domain_name, + idp_configuration_id, ): if id is not None: self._id = id @@ -238,6 +254,8 @@ def _set_values( self._auth_setting = auth_setting if domain_name: self._domain_name = domain_name + if idp_configuration_id: + self._idp_configuration_id = idp_configuration_id @classmethod def from_response(cls, resp, ns) -> list["UserItem"]: @@ -265,6 +283,7 @@ def _parse_xml(cls, element_name, resp, ns): email, auth_setting, domain_name, + idp_configuration_id, ) = cls._parse_element(user_xml, ns) user_item = cls(name, site_role) user_item._set_values( @@ -277,6 +296,7 @@ def _parse_xml(cls, element_name, resp, ns): email, auth_setting, domain_name, + idp_configuration_id, ) all_user_items.append(user_item) return all_user_items @@ -295,6 +315,7 @@ def _parse_element(user_xml, ns): fullname = user_xml.get("fullName", None) email = user_xml.get("email", None) auth_setting = user_xml.get("authSetting", None) + idp_configuration_id = user_xml.get("idpConfigurationId", None) domain_name = None domain_elem = user_xml.find(".//t:domain", namespaces=ns) @@ -311,6 +332,7 @@ def _parse_element(user_xml, ns): email, auth_setting, domain_name, + idp_configuration_id, ) class CSVImport: @@ -361,6 +383,7 @@ def create_user_from_line(line: str): values[UserItem.CSVImport.ColumnType.EMAIL], values[UserItem.CSVImport.ColumnType.AUTH], None, + None, ) return user diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index 55d2a5ad0..e2316fbb8 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -4,7 +4,7 @@ from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError from tableauserverclient.server import RequestFactory -from tableauserverclient.models import SiteItem, PaginationItem +from tableauserverclient.models import SiteAuthConfiguration, SiteItem, PaginationItem from tableauserverclient.helpers.logging import logger @@ -418,3 +418,20 @@ def re_encrypt_extracts(self, site_id: str) -> None: empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) + + @api(version="3.24") + def list_auth_configurations(self) -> list[SiteAuthConfiguration]: + """ + Lists all authentication configurations on the current site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_site.htm#list_authentication_configurations_site + + Returns + ------- + list[SiteAuthConfiguration] + A list of authentication configurations on the current site. + """ + url = f"{self.baseurl}/{self.parent_srv.site_id}/site-auth-configurations" + server_response = self.get_request(url) + auth_configurations = SiteAuthConfiguration.from_response(server_response.content, self.parent_srv.namespace) + return auth_configurations diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 575423612..c898004f7 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -913,6 +913,8 @@ def update_req(self, user_item: UserItem, password: Optional[str]) -> bytes: user_element.attrib["authSetting"] = user_item.auth_setting if password: user_element.attrib["password"] = password + if user_item.idp_configuration_id is not None: + user_element.attrib["idpConfigurationId"] = user_item.idp_configuration_id return ET.tostring(xml_request) def add_req(self, user_item: UserItem) -> bytes: @@ -929,6 +931,9 @@ def add_req(self, user_item: UserItem) -> bytes: if user_item.auth_setting: user_element.attrib["authSetting"] = user_item.auth_setting + + if user_item.idp_configuration_id is not None: + user_element.attrib["idpConfigurationId"] = user_item.idp_configuration_id return ET.tostring(xml_request) diff --git a/test/assets/site_auth_configurations.xml b/test/assets/site_auth_configurations.xml new file mode 100644 index 000000000..c81d179ac --- /dev/null +++ b/test/assets/site_auth_configurations.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/test/test_site.py b/test/test_site.py index 96b75f9ff..243810254 100644 --- a/test/test_site.py +++ b/test/test_site.py @@ -13,6 +13,7 @@ GET_BY_NAME_XML = os.path.join(TEST_ASSET_DIR, "site_get_by_name.xml") UPDATE_XML = os.path.join(TEST_ASSET_DIR, "site_update.xml") CREATE_XML = os.path.join(TEST_ASSET_DIR, "site_create.xml") +SITE_AUTH_CONFIG_XML = os.path.join(TEST_ASSET_DIR, "site_auth_configurations.xml") class SiteTests(unittest.TestCase): @@ -260,3 +261,28 @@ def test_decrypt(self) -> None: with requests_mock.mock() as m: m.post(self.baseurl + "/0626857c-1def-4503-a7d8-7907c3ff9d9f/decrypt-extracts", status_code=200) self.server.sites.decrypt_extracts("0626857c-1def-4503-a7d8-7907c3ff9d9f") + + def test_list_auth_configurations(self) -> None: + self.server.version = "3.24" + self.baseurl = self.server.sites.baseurl + with open(SITE_AUTH_CONFIG_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + + assert self.baseurl == self.server.sites.baseurl + + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{self.server.site_id}/site-auth-configurations", status_code=200, text=response_xml) + configs = self.server.sites.list_auth_configurations() + + assert len(configs) == 2, "Expected 2 auth configurations" + + assert configs[0].auth_setting == "OIDC" + assert configs[0].enabled + assert configs[0].idp_configuration_id == "00000000-0000-0000-0000-000000000000" + assert configs[0].idp_configuration_name == "Initial Salesforce" + assert configs[0].known_provider_alias == "Salesforce" + assert configs[1].auth_setting == "SAML" + assert configs[1].enabled + assert configs[1].idp_configuration_id == "11111111-1111-1111-1111-111111111111" + assert configs[1].idp_configuration_name == "Initial SAML" + assert configs[1].known_provider_alias is None diff --git a/test/test_user.py b/test/test_user.py index 645adcfd5..e258fa938 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -1,6 +1,7 @@ import os import unittest +from defusedxml import ElementTree as ET import requests_mock import tableauserverclient as TSC @@ -249,3 +250,38 @@ def test_get_users_from_file(self): users, failures = self.server.users.create_from_file(USERS) assert users[0].name == "Cassie", users assert failures == [] + + def test_add_user_idp_configuration(self) -> None: + with open(ADD_XML) as f: + response_xml = f.read() + user = TSC.UserItem(name="Cassie", site_role="Viewer") + user.idp_configuration_id = "012345" + + with requests_mock.mock() as m: + m.post(self.server.users.baseurl, text=response_xml) + user = self.server.users.add(user) + + history = m.request_history[0] + + tree = ET.fromstring(history.text) + user_elem = tree.find(".//user") + assert user_elem is not None + assert user_elem.attrib["idpConfigurationId"] == "012345" + + def test_update_user_idp_configuration(self) -> None: + with open(ADD_XML) as f: + response_xml = f.read() + user = TSC.UserItem(name="Cassie", site_role="Viewer") + user._id = "0123456789" + user.idp_configuration_id = "012345" + + with requests_mock.mock() as m: + m.put(f"{self.server.users.baseurl}/{user.id}", text=response_xml) + user = self.server.users.update(user) + + history = m.request_history[0] + + tree = ET.fromstring(history.text) + user_elem = tree.find(".//user") + assert user_elem is not None + assert user_elem.attrib["idpConfigurationId"] == "012345" From 5e112bba3f701fdae98233205fe3fe988a0ada91 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 13 May 2025 17:07:02 -0700 Subject: [PATCH 291/296] feat: Add fields:_all_ support (#1563) * feat: project support all fields * feat: groups all fields * feat: views support all fields * feat: user support _all_ fields * feat: workbook support all fields * feat: datasourceitem _all_ fields * feat: add fields methods to QuerySet * docs: Docstrings for new fields * feat: add owner attribute to project * fix: restore _all_fields but deprecated --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/__init__.py | 2 + tableauserverclient/helpers/strings.py | 26 ++- tableauserverclient/models/__init__.py | 2 + tableauserverclient/models/datasource_item.py | 108 ++++++++++++ tableauserverclient/models/group_item.py | 11 ++ tableauserverclient/models/location_item.py | 53 ++++++ tableauserverclient/models/project_item.py | 165 +++++++++++++++--- tableauserverclient/models/user_item.py | 79 ++++++++- tableauserverclient/models/view_item.py | 84 ++++++++- tableauserverclient/models/workbook_item.py | 152 +++++++++++++++- .../server/endpoint/endpoint.py | 39 +++++ .../server/endpoint/users_endpoint.py | 2 +- tableauserverclient/server/query.py | 36 ++++ tableauserverclient/server/request_options.py | 130 +++++++++++++- test/assets/datasource_get_all_fields.xml | 10 ++ test/assets/group_get_all_fields.xml | 14 ++ test/assets/project_get_all_fields.xml | 9 + test/assets/user_get_all_fields.xml | 11 ++ test/assets/view_get_all_fields.xml | 35 ++++ test/assets/workbook_get_all_fields.xml | 46 +++++ test/test_datasource.py | 39 ++++- test/test_group.py | 23 +++ test/test_project.py | 26 +++ test/test_request_option.py | 12 +- test/test_user.py | 37 +++- test/test_view.py | 116 +++++++++++- test/test_workbook.py | 106 ++++++++++- 27 files changed, 1330 insertions(+), 43 deletions(-) create mode 100644 tableauserverclient/models/location_item.py create mode 100644 test/assets/datasource_get_all_fields.xml create mode 100644 test/assets/group_get_all_fields.xml create mode 100644 test/assets/project_get_all_fields.xml create mode 100644 test/assets/user_get_all_fields.xml create mode 100644 test/assets/view_get_all_fields.xml create mode 100644 test/assets/workbook_get_all_fields.xml diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 538f85221..21e2c4760 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -25,6 +25,7 @@ LinkedTaskItem, LinkedTaskStepItem, LinkedTaskFlowRunItem, + LocationItem, MetricItem, MonthlyInterval, PaginationItem, @@ -102,6 +103,7 @@ "LinkedTaskFlowRunItem", "LinkedTaskItem", "LinkedTaskStepItem", + "LocationItem", "MetricItem", "MissingRequiredFieldError", "MonthlyInterval", diff --git a/tableauserverclient/helpers/strings.py b/tableauserverclient/helpers/strings.py index 75534103b..6ba4e48d9 100644 --- a/tableauserverclient/helpers/strings.py +++ b/tableauserverclient/helpers/strings.py @@ -1,6 +1,6 @@ from defusedxml.ElementTree import fromstring, tostring from functools import singledispatch -from typing import TypeVar +from typing import TypeVar, overload # the redact method can handle either strings or bytes, but it can't mix them. @@ -41,3 +41,27 @@ def _(xml: str) -> str: @redact_xml.register # type: ignore[no-redef] def _(xml: bytes) -> bytes: return _redact_any_type(bytearray(xml), b"password", b"..[redacted]") + + +@overload +def nullable_str_to_int(value: None) -> None: ... + + +@overload +def nullable_str_to_int(value: str) -> int: ... + + +def nullable_str_to_int(value): + return int(value) if value is not None else None + + +@overload +def nullable_str_to_bool(value: None) -> None: ... + + +@overload +def nullable_str_to_bool(value: str) -> bool: ... + + +def nullable_str_to_bool(value): + return str(value).lower() == "true" if value is not None else None diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 10c3149f1..746bb24dd 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -28,6 +28,7 @@ LinkedTaskStepItem, LinkedTaskFlowRunItem, ) +from tableauserverclient.models.location_item import LocationItem from tableauserverclient.models.metric_item import MetricItem from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.models.permissions_item import PermissionsRule, Permission @@ -75,6 +76,7 @@ "MonthlyInterval", "HourlyInterval", "BackgroundJobItem", + "LocationItem", "MetricItem", "PaginationItem", "Permission", diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 2005edf7e..de976f359 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -6,9 +6,11 @@ from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime +from tableauserverclient.helpers.strings import nullable_str_to_bool, nullable_str_to_int from tableauserverclient.models.connection_item import ConnectionItem from tableauserverclient.models.exceptions import UnpopulatedPropertyError from tableauserverclient.models.permissions_item import PermissionsRule +from tableauserverclient.models.project_item import ProjectItem from tableauserverclient.models.property_decorators import ( property_not_nullable, property_is_boolean, @@ -16,6 +18,7 @@ ) from tableauserverclient.models.revision_item import RevisionItem from tableauserverclient.models.tag_item import TagItem +from tableauserverclient.models.user_item import UserItem class DatasourceItem: @@ -40,6 +43,9 @@ class DatasourceItem: specified, it will default to SiteDefault. See REST API Publish Datasource for more information about ask_data_enablement. + connected_workbooks_count : Optional[int] + The number of workbooks connected to the datasource. + connections : list[ConnectionItem] The list of data connections (ConnectionItem) for the specified data source. You must first call the populate_connections method to access @@ -67,6 +73,12 @@ class DatasourceItem: A Boolean value to determine if a datasource should be encrypted or not. See Extract and Encryption Methods for more information. + favorites_total : Optional[int] + The number of users who have marked the data source as a favorite. + + has_alert : Optional[bool] + A Boolean value that indicates whether the data source has an alert. + has_extracts : Optional[bool] A Boolean value that indicates whether the datasource has extracts. @@ -75,13 +87,22 @@ class DatasourceItem: specific data source or to delete a data source with the get_by_id and delete methods. + is_published : Optional[bool] + A Boolean value that indicates whether the data source is published. + name : Optional[str] The name of the data source. If not specified, the name of the published data source file is used. + owner: Optional[UserItem] + The owner of the data source. + owner_id : Optional[str] The identifier of the owner of the data source. + project : Optional[ProjectItem] + The project that the data source belongs to. + project_id : Optional[str] The identifier of the project associated with the data source. You must provide this identifier when you create an instance of a DatasourceItem. @@ -89,6 +110,9 @@ class DatasourceItem: project_name : Optional[str] The name of the project associated with the data source. + server_name : Optional[str] + The name of the server where the data source is published. + tags : Optional[set[str]] The tags (list of strings) that have been added to the data source. @@ -143,6 +167,13 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) self.owner_id: Optional[str] = None self.project_id: Optional[str] = project_id self.tags: set[str] = set() + self._connected_workbooks_count: Optional[int] = None + self._favorites_total: Optional[int] = None + self._has_alert: Optional[bool] = None + self._is_published: Optional[bool] = None + self._server_name: Optional[str] = None + self._project: Optional[ProjectItem] = None + self._owner: Optional[UserItem] = None self._permissions = None self._data_quality_warnings = None @@ -274,6 +305,34 @@ def revisions(self) -> list[RevisionItem]: def size(self) -> Optional[int]: return self._size + @property + def connected_workbooks_count(self) -> Optional[int]: + return self._connected_workbooks_count + + @property + def favorites_total(self) -> Optional[int]: + return self._favorites_total + + @property + def has_alert(self) -> Optional[bool]: + return self._has_alert + + @property + def is_published(self) -> Optional[bool]: + return self._is_published + + @property + def server_name(self) -> Optional[str]: + return self._server_name + + @property + def project(self) -> Optional[ProjectItem]: + return self._project + + @property + def owner(self) -> Optional[UserItem]: + return self._owner + def _set_connections(self, connections) -> None: self._connections = connections @@ -310,6 +369,13 @@ def _parse_common_elements(self, datasource_xml, ns): use_remote_query_agent, webpage_url, size, + connected_workbooks_count, + favorites_total, + has_alert, + is_published, + server_name, + project, + owner, ) = self._parse_element(datasource_xml, ns) self._set_values( ask_data_enablement, @@ -331,6 +397,13 @@ def _parse_common_elements(self, datasource_xml, ns): use_remote_query_agent, webpage_url, size, + connected_workbooks_count, + favorites_total, + has_alert, + is_published, + server_name, + project, + owner, ) return self @@ -355,6 +428,13 @@ def _set_values( use_remote_query_agent, webpage_url, size, + connected_workbooks_count, + favorites_total, + has_alert, + is_published, + server_name, + project, + owner, ): if ask_data_enablement is not None: self._ask_data_enablement = ask_data_enablement @@ -394,6 +474,20 @@ def _set_values( self._webpage_url = webpage_url if size is not None: self._size = int(size) + if connected_workbooks_count is not None: + self._connected_workbooks_count = connected_workbooks_count + if favorites_total is not None: + self._favorites_total = favorites_total + if has_alert is not None: + self._has_alert = has_alert + if is_published is not None: + self._is_published = is_published + if server_name is not None: + self._server_name = server_name + if project is not None: + self._project = project + if owner is not None: + self._owner = owner @classmethod def from_response(cls, resp: str, ns: dict) -> list["DatasourceItem"]: @@ -428,6 +522,11 @@ def _parse_element(datasource_xml: ET.Element, ns: dict) -> tuple: use_remote_query_agent = datasource_xml.get("useRemoteQueryAgent", None) webpage_url = datasource_xml.get("webpageUrl", None) size = datasource_xml.get("size", None) + connected_workbooks_count = nullable_str_to_int(datasource_xml.get("connectedWorkbooksCount", None)) + favorites_total = nullable_str_to_int(datasource_xml.get("favoritesTotal", None)) + has_alert = nullable_str_to_bool(datasource_xml.get("hasAlert", None)) + is_published = nullable_str_to_bool(datasource_xml.get("isPublished", None)) + server_name = datasource_xml.get("serverName", None) tags = None tags_elem = datasource_xml.find(".//t:tags", namespaces=ns) @@ -438,12 +537,14 @@ def _parse_element(datasource_xml: ET.Element, ns: dict) -> tuple: project_name = None project_elem = datasource_xml.find(".//t:project", namespaces=ns) if project_elem is not None: + project = ProjectItem.from_xml(project_elem, ns) project_id = project_elem.get("id", None) project_name = project_elem.get("name", None) owner_id = None owner_elem = datasource_xml.find(".//t:owner", namespaces=ns) if owner_elem is not None: + owner = UserItem.from_xml(owner_elem, ns) owner_id = owner_elem.get("id", None) ask_data_enablement = None @@ -471,4 +572,11 @@ def _parse_element(datasource_xml: ET.Element, ns: dict) -> tuple: use_remote_query_agent, webpage_url, size, + connected_workbooks_count, + favorites_total, + has_alert, + is_published, + server_name, + project, + owner, ) diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index 0afd5582c..00f35e518 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -44,6 +44,11 @@ class GroupItem: login to a site. When the mode is onSync, a license is granted for group members each time the domain is synced. + Attributes + ---------- + user_count: Optional[int] + The number of users in the group. + Examples -------- >>> # Create a new group item @@ -65,6 +70,7 @@ def __init__(self, name=None, domain_name=None) -> None: self._users: Optional[Callable[..., "Pager"]] = None self.name: Optional[str] = name self.domain_name: Optional[str] = domain_name + self._user_count: Optional[int] = None def __repr__(self): return f"{self.__class__.__name__}({self.__dict__!r})" @@ -118,6 +124,10 @@ def users(self) -> "Pager": def _set_users(self, users: Callable[..., "Pager"]) -> None: self._users = users + @property + def user_count(self) -> Optional[int]: + return self._user_count + @classmethod def from_response(cls, resp, ns) -> list["GroupItem"]: all_group_items = list() @@ -127,6 +137,7 @@ def from_response(cls, resp, ns) -> list["GroupItem"]: name = group_xml.get("name", None) group_item = cls(name) group_item._id = group_xml.get("id", None) + group_item._user_count = int(count) if (count := group_xml.get("userCount", None)) else None # Domain name is returned in a domain element for some calls domain_elem = group_xml.find(".//t:domain", namespaces=ns) diff --git a/tableauserverclient/models/location_item.py b/tableauserverclient/models/location_item.py new file mode 100644 index 000000000..fa7c2ff2c --- /dev/null +++ b/tableauserverclient/models/location_item.py @@ -0,0 +1,53 @@ +from typing import Optional +import xml.etree.ElementTree as ET + + +class LocationItem: + """ + Details of where an item is located, such as a personal space or project. + + Attributes + ---------- + id : str | None + The ID of the location. + + type : str | None + The type of location, such as PersonalSpace or Project. + + name : str | None + The name of the location. + """ + + class Type: + PersonalSpace = "PersonalSpace" + Project = "Project" + + def __init__(self): + self._id: Optional[str] = None + self._type: Optional[str] = None + self._name: Optional[str] = None + + def __repr__(self): + return f"{self.__class__.__name__}({self.__dict__!r})" + + @property + def id(self) -> Optional[str]: + return self._id + + @property + def type(self) -> Optional[str]: + return self._type + + @property + def name(self) -> Optional[str]: + return self._name + + @classmethod + def from_xml(cls, xml: ET.Element, ns: Optional[dict] = None) -> "LocationItem": + if ns is None: + ns = {} + location = cls() + location._id = xml.get("id", None) + location._type = xml.get("type", None) + location._name = xml.get("name", None) + return location diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 9be1196ba..1ab369ba7 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -1,11 +1,11 @@ -import logging import xml.etree.ElementTree as ET -from typing import Optional +from typing import Optional, overload from defusedxml.ElementTree import fromstring from tableauserverclient.models.exceptions import UnpopulatedPropertyError -from tableauserverclient.models.property_decorators import property_is_enum, property_not_empty +from tableauserverclient.models.property_decorators import property_is_enum +from tableauserverclient.models.user_item import UserItem class ProjectItem: @@ -39,12 +39,32 @@ class corresponds to the project resources you can access using the Tableau Attributes ---------- + datasource_count : int + The number of data sources in the project. + id : str The unique identifier for the project. + owner: Optional[UserItem] + The UserItem owner of the project. + owner_id : str The unique identifier for the UserItem owner of the project. + project_count : int + The number of projects in the project. + + top_level_project : bool + True if the project is a top-level project. + + view_count : int + The number of views in the project. + + workbook_count : int + The number of workbooks in the project. + + writeable : bool + True if the project is writeable. """ ERROR_MSG = "Project item must be populated with permissions first." @@ -75,6 +95,8 @@ def __init__( self.parent_id: Optional[str] = parent_id self._samples: Optional[bool] = samples self._owner_id: Optional[str] = None + self._top_level_project: Optional[bool] = None + self._writeable: Optional[bool] = None self._permissions = None self._default_workbook_permissions = None @@ -87,6 +109,13 @@ def __init__( self._default_database_permissions = None self._default_table_permissions = None + self._project_count: Optional[int] = None + self._workbook_count: Optional[int] = None + self._view_count: Optional[int] = None + self._datasource_count: Optional[int] = None + + self._owner: Optional[UserItem] = None + @property def content_permissions(self): return self._content_permissions @@ -176,25 +205,53 @@ def owner_id(self) -> Optional[str]: def owner_id(self, value: str) -> None: self._owner_id = value + @property + def top_level_project(self) -> Optional[bool]: + return self._top_level_project + + @property + def writeable(self) -> Optional[bool]: + return self._writeable + + @property + def project_count(self) -> Optional[int]: + return self._project_count + + @property + def workbook_count(self) -> Optional[int]: + return self._workbook_count + + @property + def view_count(self) -> Optional[int]: + return self._view_count + + @property + def datasource_count(self) -> Optional[int]: + return self._datasource_count + + @property + def owner(self) -> Optional[UserItem]: + return self._owner + def is_default(self): return self.name.lower() == "default" - def _parse_common_tags(self, project_xml, ns): - if not isinstance(project_xml, ET.Element): - project_xml = fromstring(project_xml).find(".//t:project", namespaces=ns) - - if project_xml is not None: - ( - _, - name, - description, - content_permissions, - parent_id, - ) = self._parse_element(project_xml) - self._set_values(None, name, description, content_permissions, parent_id) - return self - - def _set_values(self, project_id, name, description, content_permissions, parent_id, owner_id): + def _set_values( + self, + project_id, + name, + description, + content_permissions, + parent_id, + owner_id, + top_level_project, + writeable, + project_count, + workbook_count, + view_count, + datasource_count, + owner, + ): if project_id is not None: self._id = project_id if name: @@ -207,6 +264,20 @@ def _set_values(self, project_id, name, description, content_permissions, parent self.parent_id = parent_id if owner_id: self._owner_id = owner_id + if project_count is not None: + self._project_count = project_count + if workbook_count is not None: + self._workbook_count = workbook_count + if view_count is not None: + self._view_count = view_count + if datasource_count is not None: + self._datasource_count = datasource_count + if top_level_project is not None: + self._top_level_project = top_level_project + if writeable is not None: + self._writeable = writeable + if owner is not None: + self._owner = owner def _set_permissions(self, permissions): self._permissions = permissions @@ -220,31 +291,71 @@ def _set_default_permissions(self, permissions, content_type): ) @classmethod - def from_response(cls, resp, ns) -> list["ProjectItem"]: + def from_response(cls, resp: bytes, ns: Optional[dict]) -> list["ProjectItem"]: all_project_items = list() parsed_response = fromstring(resp) all_project_xml = parsed_response.findall(".//t:project", namespaces=ns) for project_xml in all_project_xml: - project_item = cls.from_xml(project_xml) + project_item = cls.from_xml(project_xml, namespace=ns) all_project_items.append(project_item) return all_project_items @classmethod - def from_xml(cls, project_xml, namespace=None) -> "ProjectItem": + def from_xml(cls, project_xml: ET.Element, namespace: Optional[dict] = None) -> "ProjectItem": project_item = cls() - project_item._set_values(*cls._parse_element(project_xml)) + project_item._set_values(*cls._parse_element(project_xml, namespace)) return project_item @staticmethod - def _parse_element(project_xml): + def _parse_element(project_xml: ET.Element, namespace: Optional[dict]) -> tuple: id = project_xml.get("id", None) name = project_xml.get("name", None) description = project_xml.get("description", None) content_permissions = project_xml.get("contentPermissions", None) parent_id = project_xml.get("parentProjectId", None) + top_level_project = str_to_bool(project_xml.get("topLevelProject", None)) + writeable = str_to_bool(project_xml.get("writeable", None)) owner_id = None - for owner in project_xml: - owner_id = owner.get("id", None) + owner = None + if (owner_elem := project_xml.find(".//t:owner", namespaces=namespace)) is not None: + owner = UserItem.from_xml(owner_elem, namespace) + owner_id = owner_elem.get("id", None) + + project_count = None + workbook_count = None + view_count = None + datasource_count = None + if (count_elem := project_xml.find(".//t:contentsCounts", namespaces=namespace)) is not None: + project_count = int(count_elem.get("projectCount", 0)) + workbook_count = int(count_elem.get("workbookCount", 0)) + view_count = int(count_elem.get("viewCount", 0)) + datasource_count = int(count_elem.get("dataSourceCount", 0)) + + return ( + id, + name, + description, + content_permissions, + parent_id, + owner_id, + top_level_project, + writeable, + project_count, + workbook_count, + view_count, + datasource_count, + owner, + ) + + +@overload +def str_to_bool(value: str) -> bool: ... + + +@overload +def str_to_bool(value: None) -> None: ... + - return id, name, description, content_permissions, parent_id, owner_id +def str_to_bool(value): + return value.lower() == "true" if value is not None else None diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 5f6702b80..c995b4e07 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -38,6 +38,49 @@ class UserItem: auth_setting: str Required attribute for Tableau Cloud. How the user autenticates to the server. + + Attributes + ---------- + domain_name: Optional[str] + The name of the Active Directory domain ("local" if local authentication + is used). + + email: Optional[str] + The email address of the user. + + external_auth_user_id: Optional[str] + The unique identifier for the user in the external authentication system. + + id: Optional[str] + The unique identifier for the user. + + favorites: dict[str, list] + The favorites of the user. Must be populated with a call to + `populate_favorites()`. + + fullname: Optional[str] + The full name of the user. + + groups: Pager + The groups the user belongs to. Must be populated with a call to + `populate_groups()`. + + last_login: Optional[datetime] + The last time the user logged in. + + locale: Optional[str] + The locale of the user. + + language: Optional[str] + Language setting for the user. + + idp_configuration_id: Optional[str] + The ID of the identity provider configuration. + + workbooks: Pager + The workbooks owned by the user. Must be populated with a call to + `populate_workbooks()`. + """ tag_name: str = "user" @@ -95,6 +138,8 @@ def __init__( self.name: Optional[str] = name self.site_role: Optional[str] = site_role self.auth_setting: Optional[str] = auth_setting + self._locale: Optional[str] = None + self._language: Optional[str] = None self._idp_configuration_id: Optional[str] = None return None @@ -186,6 +231,14 @@ def groups(self) -> "Pager": raise UnpopulatedPropertyError(error) return self._groups() + @property + def locale(self) -> Optional[str]: + return self._locale + + @property + def language(self) -> Optional[str]: + return self._language + @property def idp_configuration_id(self) -> Optional[str]: """ @@ -219,8 +272,10 @@ def _parse_common_tags(self, user_xml, ns) -> "UserItem": auth_setting, _, _, + _, + _, ) = self._parse_element(user_xml, ns) - self._set_values(None, None, site_role, None, None, fullname, email, auth_setting, None, None) + self._set_values(None, None, site_role, None, None, fullname, email, auth_setting, None, None, None, None) return self def _set_values( @@ -234,6 +289,8 @@ def _set_values( email, auth_setting, domain_name, + locale, + language, idp_configuration_id, ): if id is not None: @@ -254,6 +311,10 @@ def _set_values( self._auth_setting = auth_setting if domain_name: self._domain_name = domain_name + if locale: + self._locale = locale + if language: + self._language = language if idp_configuration_id: self._idp_configuration_id = idp_configuration_id @@ -267,6 +328,12 @@ def from_response_as_owner(cls, resp, ns) -> list["UserItem"]: element_name = ".//t:owner" return cls._parse_xml(element_name, resp, ns) + @classmethod + def from_xml(cls, xml: ET.Element, ns: Optional[dict] = None) -> "UserItem": + item = cls() + item._set_values(*cls._parse_element(xml, ns)) + return item + @classmethod def _parse_xml(cls, element_name, resp, ns): all_user_items = [] @@ -283,6 +350,8 @@ def _parse_xml(cls, element_name, resp, ns): email, auth_setting, domain_name, + locale, + language, idp_configuration_id, ) = cls._parse_element(user_xml, ns) user_item = cls(name, site_role) @@ -296,6 +365,8 @@ def _parse_xml(cls, element_name, resp, ns): email, auth_setting, domain_name, + locale, + language, idp_configuration_id, ) all_user_items.append(user_item) @@ -315,6 +386,8 @@ def _parse_element(user_xml, ns): fullname = user_xml.get("fullName", None) email = user_xml.get("email", None) auth_setting = user_xml.get("authSetting", None) + locale = user_xml.get("locale", None) + language = user_xml.get("language", None) idp_configuration_id = user_xml.get("idpConfigurationId", None) domain_name = None @@ -332,6 +405,8 @@ def _parse_element(user_xml, ns): email, auth_setting, domain_name, + locale, + language, idp_configuration_id, ) @@ -384,6 +459,8 @@ def create_user_from_line(line: str): values[UserItem.CSVImport.ColumnType.AUTH], None, None, + None, + None, ) return user diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index 88cec7328..dc8eda9c8 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -1,15 +1,21 @@ import copy from datetime import datetime from requests import Response -from typing import Callable, Optional +from typing import TYPE_CHECKING, Callable, Optional, overload from collections.abc import Iterator from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime from tableauserverclient.models.exceptions import UnpopulatedPropertyError +from tableauserverclient.models.location_item import LocationItem from tableauserverclient.models.permissions_item import PermissionsRule +from tableauserverclient.models.project_item import ProjectItem from tableauserverclient.models.tag_item import TagItem +from tableauserverclient.models.user_item import UserItem + +if TYPE_CHECKING: + from tableauserverclient.models.workbook_item import WorkbookItem class ViewItem: @@ -34,9 +40,16 @@ class ViewItem: The image of the view. You must first call the `views.populate_image` method to access the image. + location: Optional[LocationItem], default None + The location of the view. The location can be a personal space or a + project. + name: Optional[str], default None The name of the view. + owner: Optional[UserItem], default None + The owner of the view. + owner_id: Optional[str], default None The ID for the owner of the view. @@ -48,6 +61,9 @@ class ViewItem: The preview image of the view. You must first call the `views.populate_preview_image` method to access the preview image. + project: Optional[ProjectItem], default None + The project that contains the view. + project_id: Optional[str], default None The ID for the project that contains the view. @@ -60,9 +76,11 @@ class ViewItem: updated_at: Optional[datetime], default None The date and time when the view was last updated. + workbook: Optional[WorkbookItem], default None + The workbook that contains the view. + workbook_id: Optional[str], default None The ID for the workbook that contains the view. - """ def __init__(self) -> None: @@ -84,11 +102,18 @@ def __init__(self) -> None: self._workbook_id: Optional[str] = None self._permissions: Optional[Callable[[], list[PermissionsRule]]] = None self.tags: set[str] = set() + self._favorites_total: Optional[int] = None + self._view_url_name: Optional[str] = None self._data_acceleration_config = { "acceleration_enabled": None, "acceleration_status": None, } + self._owner: Optional[UserItem] = None + self._project: Optional[ProjectItem] = None + self._workbook: Optional["WorkbookItem"] = None + self._location: Optional[LocationItem] = None + def __str__(self): return "".format( self._id, self.name, self.content_url, self.project_id @@ -190,6 +215,14 @@ def updated_at(self) -> Optional[datetime]: def workbook_id(self) -> Optional[str]: return self._workbook_id + @property + def view_url_name(self) -> Optional[str]: + return self._view_url_name + + @property + def favorites_total(self) -> Optional[int]: + return self._favorites_total + @property def data_acceleration_config(self): return self._data_acceleration_config @@ -198,6 +231,22 @@ def data_acceleration_config(self): def data_acceleration_config(self, value): self._data_acceleration_config = value + @property + def project(self) -> Optional["ProjectItem"]: + return self._project + + @property + def workbook(self) -> Optional["WorkbookItem"]: + return self._workbook + + @property + def owner(self) -> Optional[UserItem]: + return self._owner + + @property + def location(self) -> Optional[LocationItem]: + return self._location + @property def permissions(self) -> list[PermissionsRule]: if self._permissions is None: @@ -228,7 +277,7 @@ def from_xml(cls, view_xml, ns, workbook_id="") -> "ViewItem": workbook_elem = view_xml.find(".//t:workbook", namespaces=ns) 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) + 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)) @@ -236,22 +285,35 @@ def from_xml(cls, view_xml, ns, workbook_id="") -> "ViewItem": view_item._name = view_xml.get("name", None) view_item._content_url = view_xml.get("contentUrl", None) view_item._sheet_type = view_xml.get("sheetType", None) + view_item._favorites_total = string_to_int(view_xml.get("favoritesTotal", None)) + view_item._view_url_name = view_xml.get("viewUrlName", None) if usage_elem is not None: total_view = usage_elem.get("totalViewCount", None) if total_view: view_item._total_views = int(total_view) if owner_elem is not None: + user = UserItem.from_xml(owner_elem, ns) + view_item._owner = user view_item._owner_id = owner_elem.get("id", None) if project_elem is not None: - view_item._project_id = project_elem.get("id", None) + project_item = ProjectItem.from_xml(project_elem, ns) + view_item._project = project_item + view_item._project_id = project_item.id if workbook_id: view_item._workbook_id = workbook_id elif workbook_elem is not None: - view_item._workbook_id = workbook_elem.get("id", None) + from tableauserverclient.models.workbook_item import WorkbookItem + + workbook_item = WorkbookItem.from_xml(workbook_elem, ns) + view_item._workbook = workbook_item + view_item._workbook_id = workbook_item.id if tags_elem is not None: tags = TagItem.from_xml_element(tags_elem, ns) view_item.tags = tags view_item._initial_tags = copy.copy(tags) + if (location_elem := view_xml.find(".//t:location", namespaces=ns)) is not None: + location = LocationItem.from_xml(location_elem, ns) + view_item._location = location 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 @@ -274,3 +336,15 @@ def parse_data_acceleration_config(data_acceleration_elem): def string_to_bool(s: str) -> bool: return s.lower() == "true" + + +@overload +def string_to_int(s: None) -> None: ... + + +@overload +def string_to_int(s: str) -> int: ... + + +def string_to_int(s): + return int(s) if s is not None else None diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 32ab413a4..a3ede65d6 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -2,11 +2,14 @@ import datetime import uuid import xml.etree.ElementTree as ET -from typing import Callable, Optional +from typing import Callable, Optional, overload from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime +from tableauserverclient.models.location_item import LocationItem +from tableauserverclient.models.project_item import ProjectItem +from tableauserverclient.models.user_item import UserItem from .connection_item import ConnectionItem from .exceptions import UnpopulatedPropertyError from .permissions_item import PermissionsRule @@ -51,13 +54,31 @@ class as arguments. The workbook item specifies the project. created_at : Optional[datetime.datetime] The date and time the workbook was created. + default_view_id : Optional[str] + The identifier for the default view of the workbook. + description : Optional[str] User-defined description of the workbook. + encrypt_extracts : Optional[bool] + Indicates whether extracts are encrypted. + + has_extracts : Optional[bool] + Indicates whether the workbook has extracts. + id : Optional[str] The identifier for the workbook. You need this value to query a specific workbook or to delete a workbook with the get_by_id and delete methods. + last_published_at : Optional[datetime.datetime] + The date and time the workbook was last published. + + location : Optional[LocationItem] + The location of the workbook, such as a personal space or project. + + owner : Optional[UserItem] + The owner of the workbook. + owner_id : Optional[str] The identifier for the owner (UserItem) of the workbook. @@ -65,6 +86,9 @@ class as arguments. The workbook item specifies the project. The thumbnail image for the view. You must first call the workbooks.populate_preview_image method to access this data. + project: Optional[ProjectItem] + The project that contains the workbook. + project_name : Optional[str] The name of the project that contains the workbook. @@ -139,6 +163,15 @@ def __init__( self._permissions = None self.thumbnails_user_id = thumbnails_user_id self.thumbnails_group_id = thumbnails_group_id + self._sheet_count: Optional[int] = None + self._has_extracts: Optional[bool] = None + self._project: Optional[ProjectItem] = None + self._owner: Optional[UserItem] = None + self._location: Optional[LocationItem] = None + self._encrypt_extracts: Optional[bool] = None + self._default_view_id: Optional[str] = None + self._share_description: Optional[str] = None + self._last_published_at: Optional[datetime.datetime] = None return None @@ -234,6 +267,14 @@ def show_tabs(self, value: bool): def size(self): return self._size + @property + def sheet_count(self) -> Optional[int]: + return self._sheet_count + + @property + def has_extracts(self) -> Optional[bool]: + return self._has_extracts + @property def updated_at(self) -> Optional[datetime.datetime]: return self._updated_at @@ -300,6 +341,34 @@ def thumbnails_group_id(self) -> Optional[str]: def thumbnails_group_id(self, value: str): self._thumbnails_group_id = value + @property + def project(self) -> Optional[ProjectItem]: + return self._project + + @property + def owner(self) -> Optional[UserItem]: + return self._owner + + @property + def location(self) -> Optional[LocationItem]: + return self._location + + @property + def encrypt_extracts(self) -> Optional[bool]: + return self._encrypt_extracts + + @property + def default_view_id(self) -> Optional[str]: + return self._default_view_id + + @property + def share_description(self) -> Optional[str]: + return self._share_description + + @property + def last_published_at(self) -> Optional[datetime.datetime]: + return self._last_published_at + def _set_connections(self, connections): self._connections = connections @@ -342,6 +411,15 @@ def _parse_common_tags(self, workbook_xml, ns): views, data_acceleration_config, data_freshness_policy, + sheet_count, + has_extracts, + project, + owner, + location, + encrypt_extracts, + default_view_id, + share_description, + last_published_at, ) = self._parse_element(workbook_xml, ns) self._set_values( @@ -361,6 +439,15 @@ def _parse_common_tags(self, workbook_xml, ns): views, data_acceleration_config, data_freshness_policy, + sheet_count, + has_extracts, + project, + owner, + location, + encrypt_extracts, + default_view_id, + share_description, + last_published_at, ) return self @@ -383,6 +470,15 @@ def _set_values( views, data_acceleration_config, data_freshness_policy, + sheet_count, + has_extracts, + project, + owner, + location, + encrypt_extracts, + default_view_id, + share_description, + last_published_at, ): if id is not None: self._id = id @@ -417,6 +513,24 @@ def _set_values( self.data_acceleration_config = data_acceleration_config if data_freshness_policy is not None: self.data_freshness_policy = data_freshness_policy + if sheet_count is not None: + self._sheet_count = sheet_count + if has_extracts is not None: + self._has_extracts = has_extracts + if project: + self._project = project + if owner: + self._owner = owner + if location: + self._location = location + if encrypt_extracts is not None: + self._encrypt_extracts = encrypt_extracts + if default_view_id is not None: + self._default_view_id = default_view_id + if share_description is not None: + self._share_description = share_description + if last_published_at is not None: + self._last_published_at = last_published_at @classmethod def from_response(cls, resp: str, ns: dict[str, str]) -> list["WorkbookItem"]: @@ -443,6 +557,12 @@ def _parse_element(workbook_xml, ns): created_at = parse_datetime(workbook_xml.get("createdAt", None)) description = workbook_xml.get("description", None) updated_at = parse_datetime(workbook_xml.get("updatedAt", None)) + sheet_count = string_to_int(workbook_xml.get("sheetCount", None)) + has_extracts = string_to_bool(workbook_xml.get("hasExtracts", "")) + encrypt_extracts = string_to_bool(e) if (e := workbook_xml.get("encryptExtracts", None)) is not None else None + default_view_id = workbook_xml.get("defaultViewId", None) + share_description = workbook_xml.get("shareDescription", None) + last_published_at = parse_datetime(workbook_xml.get("lastPublishedAt", None)) size = workbook_xml.get("size", None) if size: @@ -452,14 +572,18 @@ def _parse_element(workbook_xml, ns): project_id = None project_name = None + project = None project_tag = workbook_xml.find(".//t:project", namespaces=ns) if project_tag is not None: + project = ProjectItem.from_xml(project_tag, ns) project_id = project_tag.get("id", None) project_name = project_tag.get("name", None) owner_id = None + owner = None owner_tag = workbook_xml.find(".//t:owner", namespaces=ns) if owner_tag is not None: + owner = UserItem.from_xml(owner_tag, ns) owner_id = owner_tag.get("id", None) tags = None @@ -473,6 +597,11 @@ def _parse_element(workbook_xml, ns): if views_elem is not None: views = ViewItem.from_xml_element(views_elem, ns) + location = None + location_elem = workbook_xml.find(".//t:location", namespaces=ns) + if location_elem is not None: + location = LocationItem.from_xml(location_elem, ns) + data_acceleration_config = { "acceleration_enabled": None, "accelerate_now": None, @@ -505,6 +634,15 @@ def _parse_element(workbook_xml, ns): views, data_acceleration_config, data_freshness_policy, + sheet_count, + has_extracts, + project, + owner, + location, + encrypt_extracts, + default_view_id, + share_description, + last_published_at, ) @@ -535,3 +673,15 @@ def parse_data_acceleration_config(data_acceleration_elem): # Used to convert string represented boolean to a boolean type def string_to_bool(s: str) -> bool: return s.lower() == "true" + + +@overload +def string_to_int(s: None) -> None: ... + + +@overload +def string_to_int(s: str) -> int: ... + + +def string_to_int(s): + return int(s) if s is not None else None diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 9e1160705..21462af5f 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -14,6 +14,7 @@ TypeVar, Union, ) +from typing_extensions import Self from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.request_options import RequestOptions @@ -353,3 +354,41 @@ def paginate(self, **kwargs) -> QuerySet[T]: @abc.abstractmethod def get(self, request_options: Optional[RequestOptions] = None) -> tuple[list[T], PaginationItem]: raise NotImplementedError(f".get has not been implemented for {self.__class__.__qualname__}") + + def fields(self: Self, *fields: str) -> QuerySet: + """ + Add fields to the request options. If no fields are provided, the + default fields will be used. If fields are provided, the default fields + will be used in addition to the provided fields. + + Parameters + ---------- + fields : str + The fields to include in the request options. + + Returns + ------- + QuerySet + """ + queryset = QuerySet(self) + queryset.request_options.fields |= set(fields) | set(("_default_",)) + return queryset + + def only_fields(self: Self, *fields: str) -> QuerySet: + """ + Add fields to the request options. If no fields are provided, the + default fields will be used. If fields are provided, the default fields + will be replaced by the provided fields. + + Parameters + ---------- + fields : str + The fields to include in the request options. + + Returns + ------- + QuerySet + """ + queryset = QuerySet(self) + queryset.request_options.fields |= set(fields) + return queryset diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 75c7bd2ed..17af21a03 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -87,7 +87,7 @@ def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[UserIt if req_options is None: req_options = RequestOptions() - req_options._all_fields = True + req_options.all_fields = True url = self.baseurl server_response = self.get_request(url, req_options) diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index 801ad4a13..5137cee52 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -208,6 +208,42 @@ def paginate(self: Self, **kwargs) -> Self: self.request_options.pagesize = kwargs["page_size"] return self + def fields(self: Self, *fields: str) -> Self: + """ + Add fields to the request options. If no fields are provided, the + default fields will be used. If fields are provided, the default fields + will be used in addition to the provided fields. + + Parameters + ---------- + fields : str + The fields to include in the request options. + + Returns + ------- + QuerySet + """ + self.request_options.fields |= set(fields) | set(("_default_")) + return self + + def only_fields(self: Self, *fields: str) -> Self: + """ + Add fields to the request options. If no fields are provided, the + default fields will be used. If fields are provided, the default fields + will be replaced by the provided fields. + + Parameters + ---------- + fields : str + The fields to include in the request options. + + Returns + ------- + QuerySet + """ + self.request_options.fields |= set(fields) + return self + @staticmethod def _parse_shorthand_filter(key: str) -> tuple[str, str]: tokens = key.split("__", 1) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 504f7f3ca..4a104255f 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,5 +1,6 @@ import sys from typing import Optional +import warnings from typing_extensions import Self @@ -62,8 +63,21 @@ def __init__(self, pagenumber=1, pagesize=None): self.pagesize = pagesize or config.PAGE_SIZE self.sort = set() self.filter = set() + self.fields = set() # This is private until we expand all of our parsers to handle the extra fields - self._all_fields = False + self.all_fields = False + + @property + def _all_fields(self) -> bool: + return self.all_fields + + @_all_fields.setter + def _all_fields(self, value): + warnings.warn( + "Directly setting _all_fields is deprecated, please use the all_fields property instead.", + DeprecationWarning, + ) + self.all_fields = value def get_query_params(self) -> dict: params = {} @@ -75,12 +89,14 @@ def get_query_params(self) -> dict: filter_options = (str(filter_item) for filter_item in self.filter) ordered_filter_options = sorted(filter_options) params["filter"] = ",".join(ordered_filter_options) - if self._all_fields: + if self.all_fields: params["fields"] = "_all_" if self.pagenumber: params["pageNumber"] = self.pagenumber if self.pagesize: params["pageSize"] = self.pagesize + if self.fields: + params["fields"] = ",".join(self.fields) return params def page_size(self, page_size): @@ -181,6 +197,116 @@ class Direction: Desc = "desc" Asc = "asc" + class SelectFields: + class Common: + All = "_all_" + Default = "_default_" + + class ContentsCounts: + ProjectCount = "contentsCounts.projectCount" + ViewCount = "contentsCounts.viewCount" + DatasourceCount = "contentsCounts.datasourceCount" + WorkbookCount = "contentsCounts.workbookCount" + + class Datasource: + ContentUrl = "datasource.contentUrl" + ID = "datasource.id" + Name = "datasource.name" + Type = "datasource.type" + Description = "datasource.description" + CreatedAt = "datasource.createdAt" + UpdatedAt = "datasource.updatedAt" + EncryptExtracts = "datasource.encryptExtracts" + IsCertified = "datasource.isCertified" + UseRemoteQueryAgent = "datasource.useRemoteQueryAgent" + WebPageURL = "datasource.webpageUrl" + Size = "datasource.size" + Tag = "datasource.tag" + FavoritesTotal = "datasource.favoritesTotal" + DatabaseName = "datasource.databaseName" + ConnectedWorkbooksCount = "datasource.connectedWorkbooksCount" + HasAlert = "datasource.hasAlert" + HasExtracts = "datasource.hasExtracts" + IsPublished = "datasource.isPublished" + ServerName = "datasource.serverName" + + class Favorite: + Label = "favorite.label" + ParentProjectName = "favorite.parentProjectName" + TargetOwnerName = "favorite.targetOwnerName" + + class Group: + ID = "group.id" + Name = "group.name" + DomainName = "group.domainName" + UserCount = "group.userCount" + MinimumSiteRole = "group.minimumSiteRole" + + class Job: + ID = "job.id" + Status = "job.status" + CreatedAt = "job.createdAt" + StartedAt = "job.startedAt" + EndedAt = "job.endedAt" + Priority = "job.priority" + JobType = "job.jobType" + Title = "job.title" + Subtitle = "job.subtitle" + + class Owner: + ID = "owner.id" + Name = "owner.name" + FullName = "owner.fullName" + SiteRole = "owner.siteRole" + LastLogin = "owner.lastLogin" + Email = "owner.email" + + class Project: + ID = "project.id" + Name = "project.name" + Description = "project.description" + CreatedAt = "project.createdAt" + UpdatedAt = "project.updatedAt" + ContentPermissions = "project.contentPermissions" + ParentProjectID = "project.parentProjectId" + TopLevelProject = "project.topLevelProject" + Writeable = "project.writeable" + + class User: + ExternalAuthUserId = "user.externalAuthUserId" + ID = "user.id" + Name = "user.name" + SiteRole = "user.siteRole" + LastLogin = "user.lastLogin" + FullName = "user.fullName" + Email = "user.email" + AuthSetting = "user.authSetting" + + class View: + ID = "view.id" + Name = "view.name" + ContentUrl = "view.contentUrl" + CreatedAt = "view.createdAt" + UpdatedAt = "view.updatedAt" + Tags = "view.tags" + SheetType = "view.sheetType" + Usage = "view.usage" + + class Workbook: + ID = "workbook.id" + Description = "workbook.description" + Name = "workbook.name" + ContentUrl = "workbook.contentUrl" + ShowTabs = "workbook.showTabs" + Size = "workbook.size" + CreatedAt = "workbook.createdAt" + UpdatedAt = "workbook.updatedAt" + SheetCount = "workbook.sheetCount" + HasExtracts = "workbook.hasExtracts" + Tags = "workbook.tags" + WebpageUrl = "workbook.webpageUrl" + DefaultViewId = "workbook.defaultViewId" + """ These options can be used by methods that are fetching data exported from a specific content item diff --git a/test/assets/datasource_get_all_fields.xml b/test/assets/datasource_get_all_fields.xml new file mode 100644 index 000000000..46c4396d3 --- /dev/null +++ b/test/assets/datasource_get_all_fields.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/test/assets/group_get_all_fields.xml b/test/assets/group_get_all_fields.xml new file mode 100644 index 000000000..0118250e1 --- /dev/null +++ b/test/assets/group_get_all_fields.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/project_get_all_fields.xml b/test/assets/project_get_all_fields.xml new file mode 100644 index 000000000..d71ebd922 --- /dev/null +++ b/test/assets/project_get_all_fields.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/test/assets/user_get_all_fields.xml b/test/assets/user_get_all_fields.xml new file mode 100644 index 000000000..7e9a62568 --- /dev/null +++ b/test/assets/user_get_all_fields.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/test/assets/view_get_all_fields.xml b/test/assets/view_get_all_fields.xml new file mode 100644 index 000000000..236ebd726 --- /dev/null +++ b/test/assets/view_get_all_fields.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/assets/workbook_get_all_fields.xml b/test/assets/workbook_get_all_fields.xml new file mode 100644 index 000000000..007b79338 --- /dev/null +++ b/test/assets/workbook_get_all_fields.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/test_datasource.py b/test/test_datasource.py index b7e7e2721..a604ba8b0 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -10,7 +10,7 @@ import tableauserverclient as TSC from tableauserverclient import ConnectionItem -from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.datetime_helpers import format_datetime, parse_datetime from tableauserverclient.server.endpoint.exceptions import InternalServerError from tableauserverclient.server.endpoint.fileuploads_endpoint import Fileuploads from tableauserverclient.server.request_factory import RequestFactory @@ -20,6 +20,7 @@ GET_XML = "datasource_get.xml" GET_EMPTY_XML = "datasource_get_empty.xml" GET_BY_ID_XML = "datasource_get_by_id.xml" +GET_XML_ALL_FIELDS = "datasource_get_all_fields.xml" POPULATE_CONNECTIONS_XML = "datasource_populate_connections.xml" POPULATE_PERMISSIONS_XML = "datasource_populate_permissions.xml" PUBLISH_XML = "datasource_publish.xml" @@ -733,3 +734,39 @@ def test_bad_download_response(self) -> None: ) file_path = self.server.datasources.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", td) self.assertTrue(os.path.exists(file_path)) + + def test_get_datasource_all_fields(self) -> None: + ro = TSC.RequestOptions() + ro.all_fields = True + with requests_mock.mock() as m: + m.get(f"{self.baseurl}?fields=_all_", text=read_xml_asset(GET_XML_ALL_FIELDS)) + datasources, _ = self.server.datasources.get(req_options=ro) + + assert datasources[0].connected_workbooks_count == 0 + assert datasources[0].content_url == "SuperstoreDatasource" + assert datasources[0].created_at == parse_datetime("2024-02-14T04:42:13Z") + assert not datasources[0].encrypt_extracts + assert datasources[0].favorites_total == 0 + assert not datasources[0].has_alert + assert not datasources[0].has_extracts + assert datasources[0].id == "a71cdd15-3a23-4ec1-b3ce-9956f5e00bb7" + assert not datasources[0].certified + assert datasources[0].is_published + assert datasources[0].name == "Superstore Datasource" + assert datasources[0].size == 1 + assert datasources[0].datasource_type == "excel-direct" + assert datasources[0].updated_at == parse_datetime("2024-02-14T04:42:14Z") + assert not datasources[0].use_remote_query_agent + assert datasources[0].server_name == "localhost" + assert datasources[0].webpage_url == "https://10ax.online.tableau.com/#/site/example/datasources/3566752" + assert isinstance(datasources[0].project, TSC.ProjectItem) + assert datasources[0].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert datasources[0].project.name == "Samples" + assert datasources[0].project.description == "This project includes automatically uploaded samples." + assert datasources[0].owner.email == "bob@example.com" + assert isinstance(datasources[0].owner, TSC.UserItem) + assert datasources[0].owner.fullname == "Bob Smith" + assert datasources[0].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert datasources[0].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert datasources[0].owner.name == "bob@example.com" + assert datasources[0].owner.site_role == "SiteAdministratorCreator" diff --git a/test/test_group.py b/test/test_group.py index 41b5992be..b3de07963 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -10,6 +10,7 @@ # TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") GET_XML = os.path.join(TEST_ASSET_DIR, "group_get.xml") +GET_XML_ALL_FIELDS = TEST_ASSET_DIR / "group_get_all_fields.xml" POPULATE_USERS = os.path.join(TEST_ASSET_DIR, "group_populate_users.xml") POPULATE_USERS_EMPTY = os.path.join(TEST_ASSET_DIR, "group_populate_users_empty.xml") ADD_USER = os.path.join(TEST_ASSET_DIR, "group_add_user.xml") @@ -310,3 +311,25 @@ def test_update_ad_async(self) -> None: self.assertEqual(job.id, "c2566efc-0767-4f15-89cb-56acb4349c1b") self.assertEqual(job.mode, "Asynchronous") self.assertEqual(job.type, "GroupSync") + + def test_get_all_fields(self) -> None: + ro = TSC.RequestOptions() + ro.all_fields = True + self.server.version = "3.21" + self.baseurl = self.server.groups.baseurl + with requests_mock.mock() as m: + m.get(f"{self.baseurl}?fields=_all_", text=GET_XML_ALL_FIELDS.read_text()) + groups, pages = self.server.groups.get(req_options=ro) + + assert pages.total_available == 3 + assert len(groups) == 3 + assert groups[0].id == "28c5b855-16df-482f-ad0b-428c1df58859" + assert groups[0].name == "All Users" + assert groups[0].user_count == 2 + assert groups[0].domain_name == "local" + assert groups[1].id == "ace1ee2d-e7dd-4d7a-9504-a1ccaa5212ea" + assert groups[1].name == "group1" + assert groups[1].user_count == 1 + assert groups[2].id == "baf0ed9d-c25d-4114-97ed-5232b8a732fd" + assert groups[2].name == "test" + assert groups[2].user_count == 0 diff --git a/test/test_project.py b/test/test_project.py index 56787efac..c51f2e1e6 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -10,6 +10,7 @@ TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") GET_XML = asset("project_get.xml") +GET_XML_ALL_FIELDS = asset("project_get_all_fields.xml") UPDATE_XML = asset("project_update.xml") SET_CONTENT_PERMISSIONS_XML = asset("project_content_permission.xml") CREATE_XML = asset("project_create.xml") @@ -410,3 +411,28 @@ def test_delete_virtualconnection_default_permimssions(self): m.delete(f"{base_url}/{endpoint}/Connect/Allow", status_code=204) self.server.projects.delete_virtualconnection_default_permissions(project, rule) + + def test_get_all_fields(self) -> None: + self.server.version = "3.23" + base_url = self.server.projects.baseurl + with open(GET_XML_ALL_FIELDS, "rb") as f: + response_xml = f.read().decode("utf-8") + + ro = TSC.RequestOptions() + ro.all_fields = True + + with requests_mock.mock() as m: + m.get(f"{base_url}?fields=_all_", text=response_xml) + all_projects, pagination_item = self.server.projects.get(req_options=ro) + + assert pagination_item.total_available == 3 + assert len(all_projects) == 1 + project: TSC.ProjectItem = all_projects[0] + assert isinstance(project, TSC.ProjectItem) + assert project.id == "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" + assert project.name == "Samples" + assert project.description == "This project includes automatically uploaded samples." + assert project.top_level_project is True + assert project.content_permissions == "ManagedByOwner" + assert project.parent_id is None + assert project.writeable is True diff --git a/test/test_request_option.py b/test/test_request_option.py index 7405189a3..57dfdc2a0 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -251,7 +251,7 @@ def test_all_fields(self) -> None: m.get(requests_mock.ANY) url = self.baseurl + "/views/456/data" opts = TSC.RequestOptions() - opts._all_fields = True + opts.all_fields = True resp = self.server.users.get_request(url, request_object=opts) self.assertTrue(re.search("fields=_all_", resp.request.query)) @@ -368,3 +368,13 @@ def test_language_export(self) -> None: resp = self.server.users.get_request(url, request_object=opts) self.assertTrue(re.search("language=en-us", resp.request.query)) + + def test_queryset_fields(self) -> None: + loop = self.server.users.fields("id") + assert "id" in loop.request_options.fields + assert "_default_" in loop.request_options.fields + + def test_queryset_only_fields(self) -> None: + loop = self.server.users.only_fields("id") + assert "id" in loop.request_options.fields + assert "_default_" not in loop.request_options.fields diff --git a/test/test_user.py b/test/test_user.py index e258fa938..fa2ac3a12 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -5,11 +5,12 @@ import requests_mock import tableauserverclient as TSC -from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.datetime_helpers import format_datetime, parse_datetime TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") GET_XML = os.path.join(TEST_ASSET_DIR, "user_get.xml") +GET_XML_ALL_FIELDS = os.path.join(TEST_ASSET_DIR, "user_get_all_fields.xml") GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "user_get_empty.xml") GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "user_get_by_id.xml") UPDATE_XML = os.path.join(TEST_ASSET_DIR, "user_update.xml") @@ -251,6 +252,40 @@ def test_get_users_from_file(self): assert users[0].name == "Cassie", users assert failures == [] + def test_get_users_all_fields(self) -> None: + self.server.version = "3.7" + baseurl = self.server.users.baseurl + with open(GET_XML_ALL_FIELDS) as f: + response_xml = f.read() + + with requests_mock.mock() as m: + m.get(f"{baseurl}?fields=_all_", text=response_xml) + all_users, _ = self.server.users.get() + + assert all_users[0].auth_setting == "TableauIDWithMFA" + assert all_users[0].email == "bob@example.com" + assert all_users[0].external_auth_user_id == "38c870c3ac5e84ec66e6ced9fb23681835b07e56d5660371ac1f705cc65bd610" + assert all_users[0].fullname == "Bob Smith" + assert all_users[0].id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert all_users[0].last_login == parse_datetime("2025-02-04T06:39:20Z") + assert all_users[0].name == "bob@example.com" + assert all_users[0].site_role == "SiteAdministratorCreator" + assert all_users[0].locale is None + assert all_users[0].language == "en" + assert all_users[0].idp_configuration_id == "22222222-2222-2222-2222-222222222222" + assert all_users[0].domain_name == "TABID_WITH_MFA" + assert all_users[1].auth_setting == "TableauIDWithMFA" + assert all_users[1].email == "alice@example.com" + assert all_users[1].external_auth_user_id == "96f66b893b22669cdfa632275d354cd1d92cea0266f3be7702151b9b8c52be29" + assert all_users[1].fullname == "Alice Jones" + assert all_users[1].id == "f6d72445-285b-48e5-8380-f90b519ce682" + assert all_users[1].name == "alice@example.com" + assert all_users[1].site_role == "ExplorerCanPublish" + assert all_users[1].locale is None + assert all_users[1].language == "en" + assert all_users[1].idp_configuration_id == "22222222-2222-2222-2222-222222222222" + assert all_users[1].domain_name == "TABID_WITH_MFA" + def test_add_user_idp_configuration(self) -> None: with open(ADD_XML) as f: response_xml = f.read() diff --git a/test/test_view.py b/test/test_view.py index 3fdaf60e6..ee6d518de 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -5,13 +5,14 @@ import tableauserverclient as TSC from tableauserverclient import UserItem, GroupItem, PermissionsRule -from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.datetime_helpers import format_datetime, parse_datetime from tableauserverclient.server.endpoint.exceptions import UnsupportedAttributeError TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") ADD_TAGS_XML = os.path.join(TEST_ASSET_DIR, "view_add_tags.xml") GET_XML = os.path.join(TEST_ASSET_DIR, "view_get.xml") +GET_XML_ALL_FIELDS = os.path.join(TEST_ASSET_DIR, "view_get_all_fields.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") @@ -402,3 +403,116 @@ def test_pdf_errors(self) -> None: req_option = TSC.PDFRequestOptions(viz_width=1920) with self.assertRaises(ValueError): req_option.get_query_params() + + def test_view_get_all_fields(self) -> None: + self.server.version = "3.21" + self.baseurl = self.server.views.baseurl + with open(GET_XML_ALL_FIELDS) as f: + response_xml = f.read() + + ro = TSC.RequestOptions() + ro.all_fields = True + + with requests_mock.mock() as m: + m.get(f"{self.baseurl}?fields=_all_", text=response_xml) + views, _ = self.server.views.get(req_options=ro) + + assert views[0].id == "2bdcd787-dcc6-4a5d-bc61-2846f1ef4534" + assert views[0].name == "Overview" + assert views[0].content_url == "Superstore/sheets/Overview" + assert views[0].created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[0].updated_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[0].sheet_type == "dashboard" + assert views[0].favorites_total == 0 + assert views[0].view_url_name == "Overview" + assert isinstance(views[0].workbook, TSC.WorkbookItem) + assert views[0].workbook.id == "9df3e2d1-070e-497a-9578-8cc557ced9df" + assert views[0].workbook.name == "Superstore" + assert views[0].workbook.content_url == "Superstore" + assert views[0].workbook.show_tabs + assert views[0].workbook.size == 2 + assert views[0].workbook.created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[0].workbook.updated_at == parse_datetime("2024-02-14T04:42:10Z") + assert views[0].workbook.sheet_count == 9 + assert not views[0].workbook.has_extracts + assert isinstance(views[0].owner, TSC.UserItem) + assert views[0].owner.email == "bob@example.com" + assert views[0].owner.fullname == "Bob" + assert views[0].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert views[0].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert views[0].owner.name == "bob@example.com" + assert views[0].owner.site_role == "SiteAdministratorCreator" + assert isinstance(views[0].project, TSC.ProjectItem) + assert views[0].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[0].project.name == "Samples" + assert views[0].project.description == "This project includes automatically uploaded samples." + assert views[0].total_views == 0 + assert isinstance(views[0].location, TSC.LocationItem) + assert views[0].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[0].location.type == "Project" + assert views[1].id == "2a3fd19d-9129-413d-9ff7-9dfc36bf7f7e" + assert views[1].name == "Product" + assert views[1].content_url == "Superstore/sheets/Product" + assert views[1].created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[1].updated_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[1].sheet_type == "dashboard" + assert views[1].favorites_total == 0 + assert views[1].view_url_name == "Product" + assert isinstance(views[1].workbook, TSC.WorkbookItem) + assert views[1].workbook.id == "9df3e2d1-070e-497a-9578-8cc557ced9df" + assert views[1].workbook.name == "Superstore" + assert views[1].workbook.content_url == "Superstore" + assert views[1].workbook.show_tabs + assert views[1].workbook.size == 2 + assert views[1].workbook.created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[1].workbook.updated_at == parse_datetime("2024-02-14T04:42:10Z") + assert views[1].workbook.sheet_count == 9 + assert not views[1].workbook.has_extracts + assert isinstance(views[1].owner, TSC.UserItem) + assert views[1].owner.email == "bob@example.com" + assert views[1].owner.fullname == "Bob" + assert views[1].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert views[1].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert views[1].owner.name == "bob@example.com" + assert views[1].owner.site_role == "SiteAdministratorCreator" + assert isinstance(views[1].project, TSC.ProjectItem) + assert views[1].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[1].project.name == "Samples" + assert views[1].project.description == "This project includes automatically uploaded samples." + assert views[1].total_views == 0 + assert isinstance(views[1].location, TSC.LocationItem) + assert views[1].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[1].location.type == "Project" + assert views[2].id == "459eda9a-85e4-46bf-a2f2-62936bd2e99a" + assert views[2].name == "Customers" + assert views[2].content_url == "Superstore/sheets/Customers" + assert views[2].created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[2].updated_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[2].sheet_type == "dashboard" + assert views[2].favorites_total == 0 + assert views[2].view_url_name == "Customers" + assert isinstance(views[2].workbook, TSC.WorkbookItem) + assert views[2].workbook.id == "9df3e2d1-070e-497a-9578-8cc557ced9df" + assert views[2].workbook.name == "Superstore" + assert views[2].workbook.content_url == "Superstore" + assert views[2].workbook.show_tabs + assert views[2].workbook.size == 2 + assert views[2].workbook.created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[2].workbook.updated_at == parse_datetime("2024-02-14T04:42:10Z") + assert views[2].workbook.sheet_count == 9 + assert not views[2].workbook.has_extracts + assert isinstance(views[2].owner, TSC.UserItem) + assert views[2].owner.email == "bob@example.com" + assert views[2].owner.fullname == "Bob" + assert views[2].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert views[2].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert views[2].owner.name == "bob@example.com" + assert views[2].owner.site_role == "SiteAdministratorCreator" + assert isinstance(views[2].project, TSC.ProjectItem) + assert views[2].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[2].project.name == "Samples" + assert views[2].project.description == "This project includes automatically uploaded samples." + assert views[2].total_views == 0 + assert isinstance(views[2].location, TSC.LocationItem) + assert views[2].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[2].location.type == "Project" diff --git a/test/test_workbook.py b/test/test_workbook.py index f3c2dd147..84afd7fcb 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -10,7 +10,7 @@ import pytest import tableauserverclient as TSC -from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.datetime_helpers import format_datetime, parse_datetime from tableauserverclient.models import UserItem, GroupItem, PermissionsRule from tableauserverclient.server.endpoint.exceptions import InternalServerError, UnsupportedAttributeError from tableauserverclient.server.request_factory import RequestFactory @@ -24,6 +24,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") +GET_XML_ALL_FIELDS = os.path.join(TEST_ASSET_DIR, "workbook_get_all_fields.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") @@ -978,3 +979,106 @@ def test_odata_connection(self) -> None: assert xml_connection is not None self.assertEqual(xml_connection.get("serverAddress"), url) + + def test_get_workbook_all_fields(self) -> None: + self.server.version = "3.21" + baseurl = self.server.workbooks.baseurl + + with open(GET_XML_ALL_FIELDS) as f: + response = f.read() + + ro = TSC.RequestOptions() + ro.all_fields = True + + with requests_mock.mock() as m: + m.get(f"{baseurl}?fields=_all_", text=response) + workbooks, _ = self.server.workbooks.get(req_options=ro) + + assert workbooks[0].id == "9df3e2d1-070e-497a-9578-8cc557ced9df" + assert workbooks[0].name == "Superstore" + assert workbooks[0].content_url == "Superstore" + assert workbooks[0].webpage_url == "https://10ax.online.tableau.com/#/site/exampledev/workbooks/265605" + assert workbooks[0].show_tabs + assert workbooks[0].size == 2 + assert workbooks[0].created_at == parse_datetime("2024-02-14T04:42:09Z") + assert workbooks[0].updated_at == parse_datetime("2024-02-14T04:42:10Z") + assert workbooks[0].sheet_count == 9 + assert not workbooks[0].has_extracts + assert not workbooks[0].encrypt_extracts + assert workbooks[0].default_view_id == "2bdcd787-dcc6-4a5d-bc61-2846f1ef4534" + assert workbooks[0].share_description == "Superstore" + assert workbooks[0].last_published_at == parse_datetime("2024-02-14T04:42:09Z") + assert isinstance(workbooks[0].project, TSC.ProjectItem) + assert workbooks[0].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert workbooks[0].project.name == "Samples" + assert workbooks[0].project.description == "This project includes automatically uploaded samples." + assert isinstance(workbooks[0].location, TSC.LocationItem) + assert workbooks[0].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert workbooks[0].location.type == "Project" + assert workbooks[0].location.name == "Samples" + assert isinstance(workbooks[0].owner, TSC.UserItem) + assert workbooks[0].owner.email == "bob@example.com" + assert workbooks[0].owner.fullname == "Bob Smith" + assert workbooks[0].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert workbooks[0].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert workbooks[0].owner.name == "bob@example.com" + assert workbooks[0].owner.site_role == "SiteAdministratorCreator" + assert workbooks[1].id == "6693cb26-9507-4174-ad3e-9de81a18c971" + assert workbooks[1].name == "World Indicators" + assert workbooks[1].content_url == "WorldIndicators" + assert workbooks[1].webpage_url == "https://10ax.online.tableau.com/#/site/exampledev/workbooks/265606" + assert workbooks[1].show_tabs + assert workbooks[1].size == 1 + assert workbooks[1].created_at == parse_datetime("2024-02-14T04:42:11Z") + assert workbooks[1].updated_at == parse_datetime("2024-02-14T04:42:12Z") + assert workbooks[1].sheet_count == 8 + assert not workbooks[1].has_extracts + assert not workbooks[1].encrypt_extracts + assert workbooks[1].default_view_id == "3d10dbcf-a206-47c7-91ba-ebab3ab33d7c" + assert workbooks[1].share_description == "World Indicators" + assert workbooks[1].last_published_at == parse_datetime("2024-02-14T04:42:11Z") + assert isinstance(workbooks[1].project, TSC.ProjectItem) + assert workbooks[1].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert workbooks[1].project.name == "Samples" + assert workbooks[1].project.description == "This project includes automatically uploaded samples." + assert isinstance(workbooks[1].location, TSC.LocationItem) + assert workbooks[1].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert workbooks[1].location.type == "Project" + assert workbooks[1].location.name == "Samples" + assert isinstance(workbooks[1].owner, TSC.UserItem) + assert workbooks[1].owner.email == "bob@example.com" + assert workbooks[1].owner.fullname == "Bob Smith" + assert workbooks[1].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert workbooks[1].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert workbooks[1].owner.name == "bob@example.com" + assert workbooks[1].owner.site_role == "SiteAdministratorCreator" + assert workbooks[2].id == "dbc0f162-909f-4edf-8392-0d12a80af955" + assert workbooks[2].name == "Superstore" + assert workbooks[2].description == "This is a superstore workbook" + assert workbooks[2].content_url == "Superstore_17078880698360" + assert workbooks[2].webpage_url == "https://10ax.online.tableau.com/#/site/exampledev/workbooks/265621" + assert not workbooks[2].show_tabs + assert workbooks[2].size == 1 + assert workbooks[2].created_at == parse_datetime("2024-02-14T05:21:09Z") + assert workbooks[2].updated_at == parse_datetime("2024-07-02T02:19:59Z") + assert workbooks[2].sheet_count == 7 + assert workbooks[2].has_extracts + assert not workbooks[2].encrypt_extracts + assert workbooks[2].default_view_id == "8c4b1d3e-3f31-4d2a-8b9f-492b92f27987" + assert workbooks[2].share_description == "Superstore" + assert workbooks[2].last_published_at == parse_datetime("2024-07-02T02:19:58Z") + assert isinstance(workbooks[2].project, TSC.ProjectItem) + assert workbooks[2].project.id == "9836791c-9468-40f0-b7f3-d10b9562a046" + assert workbooks[2].project.name == "default" + assert workbooks[2].project.description == "The default project that was automatically created by Tableau." + assert isinstance(workbooks[2].location, TSC.LocationItem) + assert workbooks[2].location.id == "9836791c-9468-40f0-b7f3-d10b9562a046" + assert workbooks[2].location.type == "Project" + assert workbooks[2].location.name == "default" + assert isinstance(workbooks[2].owner, TSC.UserItem) + assert workbooks[2].owner.email == "bob@example.com" + assert workbooks[2].owner.fullname == "Bob Smith" + assert workbooks[2].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert workbooks[2].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert workbooks[2].owner.name == "bob@example.com" + assert workbooks[2].owner.site_role == "SiteAdministratorCreator" From 823fe69f4d502e303201e76f6d5703977be45d62 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Tue, 13 May 2025 17:30:27 -0700 Subject: [PATCH 292/296] Add SSL option for connecting to Tableau Server with a weaker DH key length (#1596) * Add SSL option for connecting to Tableau Server with a weaker DH key length Fixes #1582 --- tableauserverclient/server/server.py | 42 +++++++++++++++ test/test_ssl_config.py | 77 ++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 test/test_ssl_config.py diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 30c635e31..d5d163db3 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -2,6 +2,7 @@ import requests import urllib3 +import ssl from defusedxml.ElementTree import fromstring, ParseError from packaging.version import Version @@ -91,6 +92,13 @@ class Server: and a later version of the REST API. For more information, see REST API Versions. + http_options : dict, optional + Additional options to pass to the requests library when making HTTP requests. + + session_factory : callable, optional + A factory function that returns a requests.Session object. If not provided, + requests.session is used. + Examples -------- >>> import tableauserverclient as TSC @@ -107,6 +115,16 @@ class Server: >>> # for example, 2.8 >>> # server.version = '2.8' + >>> # if connecting to an older Tableau Server with weak DH keys (Python 3.12+ only) + >>> server.configure_ssl(allow_weak_dh=True) # Note: reduces security + + Notes + ----- + When using Python 3.12 or later with older versions of Tableau Server, you may encounter + SSL errors related to weak Diffie-Hellman keys. This is because newer Python versions + enforce stronger security requirements. You can temporarily work around this using + configure_ssl(allow_weak_dh=True), but this reduces security and should only be used + as a temporary measure until the server can be upgraded. """ class PublishMode: @@ -125,6 +143,7 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self._auth_token = None self._site_id = None self._user_id = None + self._ssl_context = None # TODO: this needs to change to default to https, but without breaking existing code if not server_address.startswith("http://") and not server_address.startswith("https://"): @@ -313,3 +332,26 @@ def session(self): def is_signed_in(self): return self._auth_token is not None + + def configure_ssl(self, *, allow_weak_dh=False): + """Configure SSL/TLS settings for the server connection. + + Parameters + ---------- + allow_weak_dh : bool, optional + If True, allows connections to servers with DH keys that are considered too small by modern Python versions. + WARNING: This reduces security and should only be used as a temporary workaround. + """ + if allow_weak_dh: + logger.warning( + "WARNING: Allowing weak Diffie-Hellman keys. This reduces security and should only be used temporarily." + ) + self._ssl_context = ssl.create_default_context() + # Allow weak DH keys by setting minimum key size to 512 bits (default is 1024 in Python 3.12+) + self._ssl_context.set_dh_parameters(min_key_bits=512) + self.add_http_options({"verify": self._ssl_context}) + else: + self._ssl_context = None + # Remove any custom SSL context if we're reverting to default settings + if "verify" in self._http_options: + del self._http_options["verify"] diff --git a/test/test_ssl_config.py b/test/test_ssl_config.py new file mode 100644 index 000000000..036a326ca --- /dev/null +++ b/test/test_ssl_config.py @@ -0,0 +1,77 @@ +import unittest +import ssl +from unittest.mock import patch, MagicMock +from tableauserverclient import Server +from tableauserverclient.server.endpoint import Endpoint +import logging + + +class TestSSLConfig(unittest.TestCase): + @patch("requests.session") + @patch("tableauserverclient.server.endpoint.Endpoint.set_parameters") + def setUp(self, mock_set_parameters, mock_session): + """Set up test fixtures with mocked session and request validation""" + # Mock the session + self.mock_session = MagicMock() + mock_session.return_value = self.mock_session + + # Mock request preparation + self.mock_request = MagicMock() + self.mock_session.prepare_request.return_value = self.mock_request + + # Create server instance with mocked components + self.server = Server("http://test") + + def test_default_ssl_config(self): + """Test that by default, no custom SSL context is used""" + self.assertIsNone(self.server._ssl_context) + self.assertNotIn("verify", self.server.http_options) + + @patch("ssl.create_default_context") + def test_weak_dh_config(self, mock_create_context): + """Test that weak DH keys can be allowed when configured""" + # Setup mock SSL context + mock_context = MagicMock() + mock_create_context.return_value = mock_context + + # Configure SSL with weak DH + self.server.configure_ssl(allow_weak_dh=True) + + # Verify SSL context was created and configured correctly + mock_create_context.assert_called_once() + mock_context.set_dh_parameters.assert_called_once_with(min_key_bits=512) + + # Verify context was added to http options + self.assertEqual(self.server.http_options["verify"], mock_context) + + @patch("ssl.create_default_context") + def test_disable_weak_dh_config(self, mock_create_context): + """Test that SSL config can be reset to defaults""" + # Setup mock SSL context + mock_context = MagicMock() + mock_create_context.return_value = mock_context + + # First enable weak DH + self.server.configure_ssl(allow_weak_dh=True) + self.assertIsNotNone(self.server._ssl_context) + self.assertIn("verify", self.server.http_options) + + # Then disable it + self.server.configure_ssl(allow_weak_dh=False) + self.assertIsNone(self.server._ssl_context) + self.assertNotIn("verify", self.server.http_options) + + @patch("ssl.create_default_context") + def test_warning_on_weak_dh(self, mock_create_context): + """Test that a warning is logged when enabling weak DH keys""" + logging.getLogger().setLevel(logging.WARNING) + with self.assertLogs(level="WARNING") as log: + self.server.configure_ssl(allow_weak_dh=True) + self.assertTrue( + any("WARNING: Allowing weak Diffie-Hellman keys" in record for record in log.output), + "Expected warning about weak DH keys was not logged", + ) + + +if __name__ == "__main__": + unittest.main() From d720b1bb0ea7447ca6bf06e8cb4423d587fe2034 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 13 May 2025 17:32:30 -0700 Subject: [PATCH 293/296] chore: type hint database and table objects (#1593) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/datasource_item.py | 4 +- tableauserverclient/models/flow_item.py | 4 +- tableauserverclient/models/table_item.py | 10 +- tableauserverclient/models/tableau_types.py | 14 +- .../server/endpoint/databases_endpoint.py | 119 +++++++++++-- .../server/endpoint/datasources_endpoint.py | 6 +- .../server/endpoint/dqw_endpoint.py | 22 ++- .../server/endpoint/tables_endpoint.py | 157 ++++++++++++++++-- 8 files changed, 284 insertions(+), 52 deletions(-) diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index de976f359..5501ee332 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -339,8 +339,8 @@ def _set_connections(self, connections) -> None: def _set_permissions(self, permissions): self._permissions = permissions - def _set_data_quality_warnings(self, dqws): - self._data_quality_warnings = dqws + def _set_data_quality_warnings(self, dqw): + self._data_quality_warnings = dqw def _set_revisions(self, revisions): self._revisions = revisions diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index 0083776bb..063897e41 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -146,8 +146,8 @@ def _set_connections(self, connections): def _set_permissions(self, permissions): self._permissions = permissions - def _set_data_quality_warnings(self, dqws): - self._data_quality_warnings = dqws + def _set_data_quality_warnings(self, dqw): + self._data_quality_warnings = dqw def _parse_common_elements(self, flow_xml, ns): if not isinstance(flow_xml, ET.Element): diff --git a/tableauserverclient/models/table_item.py b/tableauserverclient/models/table_item.py index 0afdd4df3..541f84360 100644 --- a/tableauserverclient/models/table_item.py +++ b/tableauserverclient/models/table_item.py @@ -1,8 +1,12 @@ +from typing import Callable, Optional, TYPE_CHECKING from defusedxml.ElementTree import fromstring from .exceptions import UnpopulatedPropertyError from .property_decorators import property_not_empty, property_is_boolean +if TYPE_CHECKING: + from tableauserverclient.models import DQWItem + class TableItem: def __init__(self, name, description=None): @@ -40,7 +44,7 @@ def dqws(self): return self._data_quality_warnings() @property - def id(self): + def id(self) -> Optional[str]: return self._id @property @@ -100,8 +104,8 @@ def columns(self): def _set_columns(self, columns): self._columns = columns - def _set_data_quality_warnings(self, dqws): - self._data_quality_warnings = dqws + def _set_data_quality_warnings(self, dqw: Callable[[], list["DQWItem"]]) -> None: + self._data_quality_warnings = dqw def _set_values(self, table_values): if "id" in table_values: diff --git a/tableauserverclient/models/tableau_types.py b/tableauserverclient/models/tableau_types.py index 01ee3d3a9..e69d02a06 100644 --- a/tableauserverclient/models/tableau_types.py +++ b/tableauserverclient/models/tableau_types.py @@ -1,8 +1,10 @@ from typing import Union +from tableauserverclient.models.database_item import DatabaseItem from tableauserverclient.models.datasource_item import DatasourceItem from tableauserverclient.models.flow_item import FlowItem from tableauserverclient.models.project_item import ProjectItem +from tableauserverclient.models.table_item import TableItem from tableauserverclient.models.view_item import ViewItem from tableauserverclient.models.workbook_item import WorkbookItem from tableauserverclient.models.metric_item import MetricItem @@ -25,7 +27,17 @@ class Resource: # resource types that have permissions, can be renamed, etc # todo: refactoring: should actually define TableauItem as an interface and let all these implement it -TableauItem = Union[DatasourceItem, FlowItem, MetricItem, ProjectItem, ViewItem, WorkbookItem, VirtualConnectionItem] +TableauItem = Union[ + DatasourceItem, + FlowItem, + MetricItem, + ProjectItem, + ViewItem, + WorkbookItem, + VirtualConnectionItem, + DatabaseItem, + TableItem, +] def plural_type(content_type: Union[Resource, str]) -> str: diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index c0e106eb2..dc88ceaa5 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -1,7 +1,8 @@ import logging -from typing import Union +from typing import TYPE_CHECKING, Optional, Union from collections.abc import Iterable +from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.server.endpoint.default_permissions_endpoint import _DefaultPermissionsEndpoint from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint from tableauserverclient.server.endpoint.endpoint import api, Endpoint @@ -13,6 +14,10 @@ from tableauserverclient.helpers.logging import logger +if TYPE_CHECKING: + from tableauserverclient.models.dqw_item import DQWItem + from tableauserverclient.server.request_options import RequestOptions + class Databases(Endpoint, TaggingMixin): def __init__(self, parent_srv): @@ -23,11 +28,29 @@ def __init__(self, parent_srv): self._data_quality_warnings = _DataQualityWarningEndpoint(parent_srv, Resource.Database) @property - def baseurl(self): + def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/databases" @api(version="3.5") - def get(self, req_options=None): + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[DatabaseItem], PaginationItem]: + """ + Get information about all databases on the site. Endpoint is paginated, + and will return a default of 100 items per page. Use the `req_options` + parameter to customize the request. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_databases + + Parameters + ---------- + req_options : RequestOptions, optional + Options to customize the request. If not provided, defaults to None. + + Returns + ------- + tuple[list[DatabaseItem], PaginationItem] + A tuple containing a list of DatabaseItem objects and a + PaginationItem object. + """ logger.info("Querying all databases on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -37,7 +60,27 @@ def get(self, req_options=None): # Get 1 database @api(version="3.5") - def get_by_id(self, database_id): + def get_by_id(self, database_id: str) -> DatabaseItem: + """ + Get information about a single database asset on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_database + + Parameters + ---------- + database_id : str + The ID of the database to retrieve. + + Returns + ------- + DatabaseItem + A DatabaseItem object representing the database. + + Raises + ------ + ValueError + If the database ID is undefined. + """ if not database_id: error = "database ID undefined." raise ValueError(error) @@ -47,7 +90,24 @@ def get_by_id(self, database_id): return DatabaseItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="3.5") - def delete(self, database_id): + def delete(self, database_id: str) -> None: + """ + Deletes a single database asset from the server. + + Parameters + ---------- + database_id : str + The ID of the database to delete. + + Returns + ------- + None + + Raises + ------ + ValueError + If the database ID is undefined. + """ if not database_id: error = "Database ID undefined." raise ValueError(error) @@ -56,7 +116,28 @@ def delete(self, database_id): logger.info(f"Deleted single database (ID: {database_id})") @api(version="3.5") - def update(self, database_item): + def update(self, database_item: DatabaseItem) -> DatabaseItem: + """ + Update the database description, certify the database, set permissions, + or assign a User as the database contact. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#update_database + + Parameters + ---------- + database_item : DatabaseItem + The DatabaseItem object to update. + + Returns + ------- + DatabaseItem + The updated DatabaseItem object. + + Raises + ------ + MissingRequiredFieldError + If the database item is missing an ID. + """ if not database_item.id: error = "Database item missing ID." raise MissingRequiredFieldError(error) @@ -88,43 +169,45 @@ def _get_tables_for_database(self, database_item): return tables @api(version="3.5") - def populate_permissions(self, item): + def populate_permissions(self, item: DatabaseItem) -> None: self._permissions.populate(item) @api(version="3.5") - def update_permissions(self, item, rules): + def update_permissions(self, item: DatabaseItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: return self._permissions.update(item, rules) @api(version="3.5") - def delete_permission(self, item, rules): + def delete_permission(self, item: DatabaseItem, rules: list[PermissionsRule]) -> None: self._permissions.delete(item, rules) @api(version="3.5") - def populate_table_default_permissions(self, item): + def populate_table_default_permissions(self, item: DatabaseItem): self._default_permissions.populate_default_permissions(item, Resource.Table) @api(version="3.5") - def update_table_default_permissions(self, item): - return self._default_permissions.update_default_permissions(item, Resource.Table) + def update_table_default_permissions( + self, item: DatabaseItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: + return self._default_permissions.update_default_permissions(item, rules, Resource.Table) @api(version="3.5") - def delete_table_default_permissions(self, item): - self._default_permissions.delete_default_permission(item, Resource.Table) + def delete_table_default_permissions(self, rule: PermissionsRule, item: DatabaseItem) -> None: + self._default_permissions.delete_default_permission(item, rule, Resource.Table) @api(version="3.5") - def populate_dqw(self, item): + def populate_dqw(self, item: DatabaseItem) -> None: self._data_quality_warnings.populate(item) @api(version="3.5") - def update_dqw(self, item, warning): + def update_dqw(self, item: DatabaseItem, warning: "DQWItem") -> list["DQWItem"]: return self._data_quality_warnings.update(item, warning) @api(version="3.5") - def add_dqw(self, item, warning): + def add_dqw(self, item: DatabaseItem, warning: "DQWItem") -> list["DQWItem"]: return self._data_quality_warnings.add(item, warning) @api(version="3.5") - def delete_dqw(self, item): + def delete_dqw(self, item: DatabaseItem) -> None: self._data_quality_warnings.clear(item) @api(version="3.9") diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 69913a724..168446974 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -733,7 +733,7 @@ def populate_dqw(self, item) -> None: self._data_quality_warnings.populate(item) @api(version="3.5") - def update_dqw(self, item, warning): + def update_dqw(self, item: DatasourceItem, warning: "DQWItem") -> list["DQWItem"]: """ Update the warning type, status, and message of a data quality warning. @@ -755,7 +755,7 @@ def update_dqw(self, item, warning): return self._data_quality_warnings.update(item, warning) @api(version="3.5") - def add_dqw(self, item, warning): + def add_dqw(self, item: DatasourceItem, warning: "DQWItem") -> list["DQWItem"]: """ Add a data quality warning to a datasource. @@ -786,7 +786,7 @@ def add_dqw(self, item, warning): return self._data_quality_warnings.add(item, warning) @api(version="3.5") - def delete_dqw(self, item): + def delete_dqw(self, item: DatasourceItem) -> None: """ Delete a data quality warnings from an asset. diff --git a/tableauserverclient/server/endpoint/dqw_endpoint.py b/tableauserverclient/server/endpoint/dqw_endpoint.py index 90e31483b..d2ad517ee 100644 --- a/tableauserverclient/server/endpoint/dqw_endpoint.py +++ b/tableauserverclient/server/endpoint/dqw_endpoint.py @@ -1,4 +1,5 @@ import logging +from typing import Callable, Optional, Protocol, TYPE_CHECKING from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError @@ -7,6 +8,15 @@ from tableauserverclient.helpers.logging import logger +if TYPE_CHECKING: + from tableauserverclient.server.request_options import RequestOptions + + +class HasId(Protocol): + @property + def id(self) -> Optional[str]: ... + def _set_data_quality_warnings(self, dqw: Callable[[], list[DQWItem]]): ... + class _DataQualityWarningEndpoint(Endpoint): def __init__(self, parent_srv, resource_type): @@ -14,12 +24,12 @@ def __init__(self, parent_srv, resource_type): self.resource_type = resource_type @property - def baseurl(self): + def baseurl(self) -> str: return "{}/sites/{}/dataQualityWarnings/{}".format( self.parent_srv.baseurl, self.parent_srv.site_id, self.resource_type ) - def add(self, resource, warning): + def add(self, resource: HasId, warning: DQWItem) -> list[DQWItem]: url = f"{self.baseurl}/{resource.id}" add_req = RequestFactory.DQW.add_req(warning) response = self.post_request(url, add_req) @@ -28,7 +38,7 @@ def add(self, resource, warning): return warnings - def update(self, resource, warning): + def update(self, resource: HasId, warning: DQWItem) -> list[DQWItem]: url = f"{self.baseurl}/{resource.id}" add_req = RequestFactory.DQW.update_req(warning) response = self.put_request(url, add_req) @@ -37,11 +47,11 @@ def update(self, resource, warning): return warnings - def clear(self, resource): + def clear(self, resource: HasId) -> None: url = f"{self.baseurl}/{resource.id}" return self.delete_request(url) - def populate(self, item): + def populate(self, item: HasId) -> None: if not item.id: error = "Server item is missing ID. Item must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -52,7 +62,7 @@ def dqw_fetcher(): item._set_data_quality_warnings(dqw_fetcher) logger.info(f"Populated permissions for item (ID: {item.id})") - def _get_data_quality_warnings(self, item, req_options=None): + def _get_data_quality_warnings(self, item: HasId, req_options: Optional["RequestOptions"] = None) -> list[DQWItem]: url = f"{self.baseurl}/{item.id}" server_response = self.get_request(url, req_options) dqws = DQWItem.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index 120d3ba9c..ad80e7d0e 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -1,7 +1,8 @@ import logging -from typing import Union +from typing import Optional, Union, TYPE_CHECKING from collections.abc import Iterable +from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint from tableauserverclient.server.endpoint.endpoint import api, Endpoint from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError @@ -12,6 +13,10 @@ from tableauserverclient.server.pager import Pager from tableauserverclient.helpers.logging import logger +from tableauserverclient.server.request_options import RequestOptions + +if TYPE_CHECKING: + from tableauserverclient.models import DQWItem, PermissionsRule class Tables(Endpoint, TaggingMixin[TableItem]): @@ -22,11 +27,29 @@ def __init__(self, parent_srv): self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "table") @property - def baseurl(self): + def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tables" @api(version="3.5") - def get(self, req_options=None): + def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[TableItem], PaginationItem]: + """ + Get information about all tables on the site. Endpoint is paginated, and + will return a default of 100 items per page. Use the `req_options` + parameter to customize the request. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_tables + + Parameters + ---------- + req_options : RequestOptions, optional + Options to customize the request. If not provided, defaults to None. + + Returns + ------- + tuple[list[TableItem], PaginationItem] + A tuple containing a list of TableItem objects and a PaginationItem + object. + """ logger.info("Querying all tables on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -36,7 +59,27 @@ def get(self, req_options=None): # Get 1 table @api(version="3.5") - def get_by_id(self, table_id): + def get_by_id(self, table_id: str) -> TableItem: + """ + Get information about a single table on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_table + + Parameters + ---------- + table_id : str + The ID of the table to retrieve. + + Returns + ------- + TableItem + A TableItem object representing the table. + + Raises + ------ + ValueError + If the table ID is not provided. + """ if not table_id: error = "table ID undefined." raise ValueError(error) @@ -46,7 +89,24 @@ def get_by_id(self, table_id): return TableItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="3.5") - def delete(self, table_id): + def delete(self, table_id: str) -> None: + """ + Delete a single table from the server. + + Parameters + ---------- + table_id : str + The ID of the table to delete. + + Returns + ------- + None + + Raises + ------ + ValueError + If the table ID is not provided. + """ if not table_id: error = "Database ID undefined." raise ValueError(error) @@ -55,7 +115,27 @@ def delete(self, table_id): logger.info(f"Deleted single table (ID: {table_id})") @api(version="3.5") - def update(self, table_item): + def update(self, table_item: TableItem) -> TableItem: + """ + Update a table on the server. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#update_table + + Parameters + ---------- + table_item : TableItem + The TableItem object to update. + + Returns + ------- + TableItem + The updated TableItem object. + + Raises + ------ + MissingRequiredFieldError + If the table item is missing an ID. + """ if not table_item.id: error = "table item missing ID." raise MissingRequiredFieldError(error) @@ -69,21 +149,46 @@ def update(self, table_item): # Get all columns of the table @api(version="3.5") - def populate_columns(self, table_item, req_options=None): + def populate_columns(self, table_item: TableItem, req_options: Optional[RequestOptions] = None) -> None: + """ + Populate the columns of a table item. Sets a fetcher function to + retrieve the columns when needed. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_columns + + Parameters + ---------- + table_item : TableItem + The TableItem object to populate columns for. + + req_options : RequestOptions, optional + Options to customize the request. If not provided, defaults to None. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the table item is missing an ID. + """ if not table_item.id: error = "Table item missing ID. table must be retrieved from server first." raise MissingRequiredFieldError(error) def column_fetcher(): return Pager( - lambda options: self._get_columns_for_table(table_item, options), + lambda options: self._get_columns_for_table(table_item, options), # type: ignore req_options, ) table_item._set_columns(column_fetcher) logger.info(f"Populated columns for table (ID: {table_item.id}") - def _get_columns_for_table(self, table_item, req_options=None): + def _get_columns_for_table( + self, table_item: TableItem, req_options: Optional[RequestOptions] = None + ) -> tuple[list[ColumnItem], PaginationItem]: url = f"{self.baseurl}/{table_item.id}/columns" server_response = self.get_request(url, req_options) columns = ColumnItem.from_response(server_response.content, self.parent_srv.namespace) @@ -91,7 +196,25 @@ def _get_columns_for_table(self, table_item, req_options=None): return columns, pagination_item @api(version="3.5") - def update_column(self, table_item, column_item): + def update_column(self, table_item: TableItem, column_item: ColumnItem) -> ColumnItem: + """ + Update the description of a column in a table. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#update_column + + Parameters + ---------- + table_item : TableItem + The TableItem object representing the table. + + column_item : ColumnItem + The ColumnItem object representing the column to update. + + Returns + ------- + ColumnItem + The updated ColumnItem object. + """ url = f"{self.baseurl}/{table_item.id}/columns/{column_item.id}" update_req = RequestFactory.Column.update_req(column_item) server_response = self.put_request(url, update_req) @@ -101,31 +224,31 @@ def update_column(self, table_item, column_item): return column @api(version="3.5") - def populate_permissions(self, item): + def populate_permissions(self, item: TableItem) -> None: self._permissions.populate(item) @api(version="3.5") - def update_permissions(self, item, rules): + def update_permissions(self, item: TableItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: return self._permissions.update(item, rules) @api(version="3.5") - def delete_permission(self, item, rules): + def delete_permission(self, item: TableItem, rules: list[PermissionsRule]) -> None: return self._permissions.delete(item, rules) @api(version="3.5") - def populate_dqw(self, item): + def populate_dqw(self, item: TableItem) -> None: self._data_quality_warnings.populate(item) @api(version="3.5") - def update_dqw(self, item, warning): + def update_dqw(self, item: TableItem, warning: "DQWItem") -> list["DQWItem"]: return self._data_quality_warnings.update(item, warning) @api(version="3.5") - def add_dqw(self, item, warning): + def add_dqw(self, item: TableItem, warning: "DQWItem") -> list["DQWItem"]: return self._data_quality_warnings.add(item, warning) @api(version="3.5") - def delete_dqw(self, item): + def delete_dqw(self, item: TableItem) -> None: self._data_quality_warnings.clear(item) @api(version="3.9") From f9bc99bb91346de3d77b2046aa727afa54611029 Mon Sep 17 00:00:00 2001 From: casey-crawford-cfa <91914995+casey-crawford-cfa@users.noreply.github.com> Date: Wed, 14 May 2025 18:54:50 -0500 Subject: [PATCH 294/296] 1580 list extracts on schedule (#1604) * determine what datasources or workbooks are associated with a schedule --- samples/extracts.py | 1 - tableauserverclient/models/__init__.py | 2 + tableauserverclient/models/extract_item.py | 82 +++++++++++++++++++ .../server/endpoint/schedules_endpoint.py | 20 ++++- .../schedule_get_extract_refresh_tasks.xml | 15 ++++ test/request_factory/test_task_requests.py | 1 - test/test_schedule.py | 18 ++++ 7 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 tableauserverclient/models/extract_item.py create mode 100644 test/assets/schedule_get_extract_refresh_tasks.xml diff --git a/samples/extracts.py b/samples/extracts.py index 8e7a66aac..d9289452a 100644 --- a/samples/extracts.py +++ b/samples/extracts.py @@ -42,7 +42,6 @@ def main(): server.add_http_options({"verify": False}) server.use_server_version() with server.auth.sign_in(tableau_auth): - wb = None ds = None if args.workbook: diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 746bb24dd..30cd88104 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -49,6 +49,7 @@ from tableauserverclient.models.virtual_connection_item import VirtualConnectionItem from tableauserverclient.models.webhook_item import WebhookItem from tableauserverclient.models.workbook_item import WorkbookItem +from tableauserverclient.models.extract_item import ExtractItem __all__ = [ "ColumnItem", @@ -106,4 +107,5 @@ "LinkedTaskItem", "LinkedTaskStepItem", "LinkedTaskFlowRunItem", + "ExtractItem", ] diff --git a/tableauserverclient/models/extract_item.py b/tableauserverclient/models/extract_item.py new file mode 100644 index 000000000..7562ffdde --- /dev/null +++ b/tableauserverclient/models/extract_item.py @@ -0,0 +1,82 @@ +from typing import Optional, List +from defusedxml.ElementTree import fromstring +import xml.etree.ElementTree as ET + + +class ExtractItem: + """ + An extract refresh task item. + + Attributes + ---------- + id : str + The ID of the extract refresh task + priority : int + The priority of the task + type : str + The type of extract refresh (incremental or full) + workbook_id : str, optional + The ID of the workbook if this is a workbook extract + datasource_id : str, optional + The ID of the datasource if this is a datasource extract + """ + + def __init__( + self, priority: int, refresh_type: str, workbook_id: Optional[str] = None, datasource_id: Optional[str] = None + ): + self._id: Optional[str] = None + self._priority = priority + self._type = refresh_type + self._workbook_id = workbook_id + self._datasource_id = datasource_id + + @property + def id(self) -> Optional[str]: + return self._id + + @property + def priority(self) -> int: + return self._priority + + @property + def type(self) -> str: + return self._type + + @property + def workbook_id(self) -> Optional[str]: + return self._workbook_id + + @property + def datasource_id(self) -> Optional[str]: + return self._datasource_id + + @classmethod + def from_response(cls, resp: str, ns: dict) -> List["ExtractItem"]: + """Create ExtractItem objects from XML response.""" + parsed_response = fromstring(resp) + return cls.from_xml_element(parsed_response, ns) + + @classmethod + def from_xml_element(cls, parsed_response: ET.Element, ns: dict) -> List["ExtractItem"]: + """Create ExtractItem objects from XML element.""" + all_extract_items = [] + all_extract_xml = parsed_response.findall(".//t:extract", namespaces=ns) + + for extract_xml in all_extract_xml: + extract_id = extract_xml.get("id", None) + priority = int(extract_xml.get("priority", 0)) + refresh_type = extract_xml.get("type", "") + + # Check for workbook or datasource + workbook_elem = extract_xml.find(".//t:workbook", namespaces=ns) + datasource_elem = extract_xml.find(".//t:datasource", namespaces=ns) + + workbook_id = workbook_elem.get("id", None) if workbook_elem is not None else None + datasource_id = datasource_elem.get("id", None) if datasource_elem is not None else None + + extract_item = cls(priority, refresh_type, workbook_id, datasource_id) + extract_item._id = extract_id + + all_extract_items.append(extract_item) + + return all_extract_items diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index 8693d66cc..090d400b6 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -7,7 +7,7 @@ from .endpoint import Endpoint, api, parameter_added_in from .exceptions import MissingRequiredFieldError from tableauserverclient.server import RequestFactory -from tableauserverclient.models import PaginationItem, ScheduleItem, TaskItem +from tableauserverclient.models import PaginationItem, ScheduleItem, TaskItem, ExtractItem from tableauserverclient.helpers.logging import logger @@ -261,3 +261,21 @@ def _add_to( ) else: return OK + + @api(version="2.3") + def get_extract_refresh_tasks( + self, schedule_id: str, req_options: Optional["RequestOptions"] = None + ) -> tuple[list["ExtractItem"], "PaginationItem"]: + """Get all extract refresh tasks for the specified schedule.""" + if not schedule_id: + error = "Schedule ID undefined" + raise ValueError(error) + + logger.info(f"Querying extract refresh tasks for schedule (ID: {schedule_id})") + url = f"{self.siteurl}/{schedule_id}/extracts" + server_response = self.get_request(url, req_options) + + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + extract_items = ExtractItem.from_response(server_response.content, self.parent_srv.namespace) + + return extract_items, pagination_item diff --git a/test/assets/schedule_get_extract_refresh_tasks.xml b/test/assets/schedule_get_extract_refresh_tasks.xml new file mode 100644 index 000000000..48906dde6 --- /dev/null +++ b/test/assets/schedule_get_extract_refresh_tasks.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/test/request_factory/test_task_requests.py b/test/request_factory/test_task_requests.py index 0258b8a93..6287fa6ea 100644 --- a/test/request_factory/test_task_requests.py +++ b/test/request_factory/test_task_requests.py @@ -5,7 +5,6 @@ class TestTaskRequest(unittest.TestCase): - def setUp(self): self.task_request = TaskRequest() self.xml_request = ET.Element("tsRequest") diff --git a/test/test_schedule.py b/test/test_schedule.py index b072522a4..4fcc85e18 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -25,6 +25,7 @@ ADD_WORKBOOK_TO_SCHEDULE_WITH_WARNINGS = os.path.join(TEST_ASSET_DIR, "schedule_add_workbook_with_warnings.xml") ADD_DATASOURCE_TO_SCHEDULE = os.path.join(TEST_ASSET_DIR, "schedule_add_datasource.xml") ADD_FLOW_TO_SCHEDULE = os.path.join(TEST_ASSET_DIR, "schedule_add_flow.xml") +GET_EXTRACT_TASKS_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_extract_refresh_tasks.xml") WORKBOOK_GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_by_id.xml") DATASOURCE_GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "datasource_get_by_id.xml") @@ -405,3 +406,20 @@ def test_add_flow(self) -> None: flow = self.server.flows.get_by_id("bar") result = self.server.schedules.add_to_schedule("foo", flow=flow) self.assertEqual(0, len(result), "Added properly") + + def test_get_extract_refresh_tasks(self) -> None: + self.server.version = "2.3" + + with open(GET_EXTRACT_TASKS_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" + baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules/{schedule_id}/extracts" + m.get(baseurl, text=response_xml) + + extracts = self.server.schedules.get_extract_refresh_tasks(schedule_id) + + self.assertIsNotNone(extracts) + self.assertIsInstance(extracts[0], list) + self.assertEqual(2, len(extracts[0])) + self.assertEqual("task1", extracts[0][0].id) From cda018b684c98200f3f9de11e379ba2ca3b1b3fe Mon Sep 17 00:00:00 2001 From: Jac Date: Thu, 15 May 2025 14:21:50 -0700 Subject: [PATCH 295/296] v0.38 - IDP configuration (#1606) * docs: docstrings for schedules and intervals (#1528) * docs: Docstrings for new fields * feat: enable retrieving only owned workbooks * feat: Add support for multiple IDPs (jorwoods) * feat: Add fields:_all_ support (#1563) * feat: project support all fields * feat: groups all fields * feat: views support all fields * feat: user support _all_ fields * feat: workbook support all fields * feat: datasourceitem _all_ fields * feat: add fields methods to QuerySet * feat: add owner attribute to project * Add SSL option for connecting to Tableau Server with a weaker DH key length Fixes #1582 * feat: 1580 list extracts on schedule (#1604) * chore: type hint database and table objects (#1593) * ci: Switch Slack action to use `ubuntu-latest` like our other actions. --------- Co-authored-by: Jordan Woods Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Co-authored-by: Brian Cantoni Co-authored-by: casey-crawford-cfa <91914995+casey-crawford-cfa@users.noreply.github.com> --- .github/workflows/slack.yml | 2 +- samples/extracts.py | 1 - tableauserverclient/__init__.py | 4 + tableauserverclient/helpers/strings.py | 26 ++- tableauserverclient/models/__init__.py | 7 +- tableauserverclient/models/datasource_item.py | 112 +++++++++++- tableauserverclient/models/extract_item.py | 82 +++++++++ tableauserverclient/models/flow_item.py | 4 +- tableauserverclient/models/group_item.py | 11 ++ tableauserverclient/models/interval_item.py | 40 +++++ tableauserverclient/models/location_item.py | 53 ++++++ tableauserverclient/models/project_item.py | 165 +++++++++++++++--- tableauserverclient/models/schedule_item.py | 57 ++++++ tableauserverclient/models/site_item.py | 28 +++ tableauserverclient/models/table_item.py | 10 +- tableauserverclient/models/tableau_types.py | 14 +- tableauserverclient/models/user_item.py | 102 ++++++++++- tableauserverclient/models/view_item.py | 84 ++++++++- tableauserverclient/models/workbook_item.py | 152 +++++++++++++++- .../server/endpoint/databases_endpoint.py | 119 +++++++++++-- .../server/endpoint/datasources_endpoint.py | 6 +- .../server/endpoint/dqw_endpoint.py | 22 ++- .../server/endpoint/endpoint.py | 39 +++++ .../server/endpoint/schedules_endpoint.py | 134 +++++++++++++- .../server/endpoint/sites_endpoint.py | 19 +- .../server/endpoint/tables_endpoint.py | 157 +++++++++++++++-- .../server/endpoint/users_endpoint.py | 27 ++- tableauserverclient/server/query.py | 36 ++++ tableauserverclient/server/request_factory.py | 5 + tableauserverclient/server/request_options.py | 130 +++++++++++++- tableauserverclient/server/server.py | 42 +++++ test/assets/datasource_get_all_fields.xml | 10 ++ test/assets/group_get_all_fields.xml | 14 ++ test/assets/project_get_all_fields.xml | 9 + .../schedule_get_extract_refresh_tasks.xml | 15 ++ test/assets/site_auth_configurations.xml | 18 ++ test/assets/user_get_all_fields.xml | 11 ++ test/assets/view_get_all_fields.xml | 35 ++++ test/assets/workbook_get_all_fields.xml | 46 +++++ test/request_factory/test_task_requests.py | 1 - test/test_datasource.py | 39 ++++- test/test_group.py | 23 +++ test/test_project.py | 26 +++ test/test_request_option.py | 12 +- test/test_schedule.py | 18 ++ test/test_site.py | 26 +++ test/test_ssl_config.py | 77 ++++++++ test/test_user.py | 89 +++++++++- test/test_view.py | 116 +++++++++++- test/test_workbook.py | 106 ++++++++++- 50 files changed, 2275 insertions(+), 106 deletions(-) create mode 100644 tableauserverclient/models/extract_item.py create mode 100644 tableauserverclient/models/location_item.py create mode 100644 test/assets/datasource_get_all_fields.xml create mode 100644 test/assets/group_get_all_fields.xml create mode 100644 test/assets/project_get_all_fields.xml create mode 100644 test/assets/schedule_get_extract_refresh_tasks.xml create mode 100644 test/assets/site_auth_configurations.xml create mode 100644 test/assets/user_get_all_fields.xml create mode 100644 test/assets/view_get_all_fields.xml create mode 100644 test/assets/workbook_get_all_fields.xml create mode 100644 test/test_ssl_config.py diff --git a/.github/workflows/slack.yml b/.github/workflows/slack.yml index 2ecb0be7f..9afebf25b 100644 --- a/.github/workflows/slack.yml +++ b/.github/workflows/slack.yml @@ -5,7 +5,7 @@ on: [push, pull_request, issues] jobs: slack-notifications: continue-on-error: true - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest name: Sends a message to Slack when a push, a pull request or an issue is made steps: - name: Send message to Slack API diff --git a/samples/extracts.py b/samples/extracts.py index 8e7a66aac..d9289452a 100644 --- a/samples/extracts.py +++ b/samples/extracts.py @@ -42,7 +42,6 @@ def main(): server.add_http_options({"verify": False}) server.use_server_version() with server.auth.sign_in(tableau_auth): - wb = None ds = None if args.workbook: diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 957a820db..21e2c4760 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -25,6 +25,7 @@ LinkedTaskItem, LinkedTaskStepItem, LinkedTaskFlowRunItem, + LocationItem, MetricItem, MonthlyInterval, PaginationItem, @@ -35,6 +36,7 @@ Resource, RevisionItem, ScheduleItem, + SiteAuthConfiguration, SiteItem, ServerInfoItem, SubscriptionItem, @@ -101,6 +103,7 @@ "LinkedTaskFlowRunItem", "LinkedTaskItem", "LinkedTaskStepItem", + "LocationItem", "MetricItem", "MissingRequiredFieldError", "MonthlyInterval", @@ -121,6 +124,7 @@ "ServerInfoItem", "ServerResponseError", "SiteItem", + "SiteAuthConfiguration", "Sort", "SubscriptionItem", "TableauAuth", diff --git a/tableauserverclient/helpers/strings.py b/tableauserverclient/helpers/strings.py index 75534103b..6ba4e48d9 100644 --- a/tableauserverclient/helpers/strings.py +++ b/tableauserverclient/helpers/strings.py @@ -1,6 +1,6 @@ from defusedxml.ElementTree import fromstring, tostring from functools import singledispatch -from typing import TypeVar +from typing import TypeVar, overload # the redact method can handle either strings or bytes, but it can't mix them. @@ -41,3 +41,27 @@ def _(xml: str) -> str: @redact_xml.register # type: ignore[no-redef] def _(xml: bytes) -> bytes: return _redact_any_type(bytearray(xml), b"password", b"..[redacted]") + + +@overload +def nullable_str_to_int(value: None) -> None: ... + + +@overload +def nullable_str_to_int(value: str) -> int: ... + + +def nullable_str_to_int(value): + return int(value) if value is not None else None + + +@overload +def nullable_str_to_bool(value: None) -> None: ... + + +@overload +def nullable_str_to_bool(value: str) -> bool: ... + + +def nullable_str_to_bool(value): + return str(value).lower() == "true" if value is not None else None diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index e4131b720..30cd88104 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -28,6 +28,7 @@ LinkedTaskStepItem, LinkedTaskFlowRunItem, ) +from tableauserverclient.models.location_item import LocationItem from tableauserverclient.models.metric_item import MetricItem from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.models.permissions_item import PermissionsRule, Permission @@ -35,7 +36,7 @@ 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.site_item import SiteItem, SiteAuthConfiguration from tableauserverclient.models.subscription_item import SubscriptionItem from tableauserverclient.models.table_item import TableItem from tableauserverclient.models.tableau_auth import Credentials, TableauAuth, PersonalAccessTokenAuth, JWTAuth @@ -48,6 +49,7 @@ from tableauserverclient.models.virtual_connection_item import VirtualConnectionItem from tableauserverclient.models.webhook_item import WebhookItem from tableauserverclient.models.workbook_item import WorkbookItem +from tableauserverclient.models.extract_item import ExtractItem __all__ = [ "ColumnItem", @@ -75,6 +77,7 @@ "MonthlyInterval", "HourlyInterval", "BackgroundJobItem", + "LocationItem", "MetricItem", "PaginationItem", "Permission", @@ -83,6 +86,7 @@ "RevisionItem", "ScheduleItem", "ServerInfoItem", + "SiteAuthConfiguration", "SiteItem", "SubscriptionItem", "TableItem", @@ -103,4 +107,5 @@ "LinkedTaskItem", "LinkedTaskStepItem", "LinkedTaskFlowRunItem", + "ExtractItem", ] diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 2005edf7e..5501ee332 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -6,9 +6,11 @@ from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime +from tableauserverclient.helpers.strings import nullable_str_to_bool, nullable_str_to_int from tableauserverclient.models.connection_item import ConnectionItem from tableauserverclient.models.exceptions import UnpopulatedPropertyError from tableauserverclient.models.permissions_item import PermissionsRule +from tableauserverclient.models.project_item import ProjectItem from tableauserverclient.models.property_decorators import ( property_not_nullable, property_is_boolean, @@ -16,6 +18,7 @@ ) from tableauserverclient.models.revision_item import RevisionItem from tableauserverclient.models.tag_item import TagItem +from tableauserverclient.models.user_item import UserItem class DatasourceItem: @@ -40,6 +43,9 @@ class DatasourceItem: specified, it will default to SiteDefault. See REST API Publish Datasource for more information about ask_data_enablement. + connected_workbooks_count : Optional[int] + The number of workbooks connected to the datasource. + connections : list[ConnectionItem] The list of data connections (ConnectionItem) for the specified data source. You must first call the populate_connections method to access @@ -67,6 +73,12 @@ class DatasourceItem: A Boolean value to determine if a datasource should be encrypted or not. See Extract and Encryption Methods for more information. + favorites_total : Optional[int] + The number of users who have marked the data source as a favorite. + + has_alert : Optional[bool] + A Boolean value that indicates whether the data source has an alert. + has_extracts : Optional[bool] A Boolean value that indicates whether the datasource has extracts. @@ -75,13 +87,22 @@ class DatasourceItem: specific data source or to delete a data source with the get_by_id and delete methods. + is_published : Optional[bool] + A Boolean value that indicates whether the data source is published. + name : Optional[str] The name of the data source. If not specified, the name of the published data source file is used. + owner: Optional[UserItem] + The owner of the data source. + owner_id : Optional[str] The identifier of the owner of the data source. + project : Optional[ProjectItem] + The project that the data source belongs to. + project_id : Optional[str] The identifier of the project associated with the data source. You must provide this identifier when you create an instance of a DatasourceItem. @@ -89,6 +110,9 @@ class DatasourceItem: project_name : Optional[str] The name of the project associated with the data source. + server_name : Optional[str] + The name of the server where the data source is published. + tags : Optional[set[str]] The tags (list of strings) that have been added to the data source. @@ -143,6 +167,13 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) self.owner_id: Optional[str] = None self.project_id: Optional[str] = project_id self.tags: set[str] = set() + self._connected_workbooks_count: Optional[int] = None + self._favorites_total: Optional[int] = None + self._has_alert: Optional[bool] = None + self._is_published: Optional[bool] = None + self._server_name: Optional[str] = None + self._project: Optional[ProjectItem] = None + self._owner: Optional[UserItem] = None self._permissions = None self._data_quality_warnings = None @@ -274,14 +305,42 @@ def revisions(self) -> list[RevisionItem]: def size(self) -> Optional[int]: return self._size + @property + def connected_workbooks_count(self) -> Optional[int]: + return self._connected_workbooks_count + + @property + def favorites_total(self) -> Optional[int]: + return self._favorites_total + + @property + def has_alert(self) -> Optional[bool]: + return self._has_alert + + @property + def is_published(self) -> Optional[bool]: + return self._is_published + + @property + def server_name(self) -> Optional[str]: + return self._server_name + + @property + def project(self) -> Optional[ProjectItem]: + return self._project + + @property + def owner(self) -> Optional[UserItem]: + return self._owner + def _set_connections(self, connections) -> None: self._connections = connections def _set_permissions(self, permissions): self._permissions = permissions - def _set_data_quality_warnings(self, dqws): - self._data_quality_warnings = dqws + def _set_data_quality_warnings(self, dqw): + self._data_quality_warnings = dqw def _set_revisions(self, revisions): self._revisions = revisions @@ -310,6 +369,13 @@ def _parse_common_elements(self, datasource_xml, ns): use_remote_query_agent, webpage_url, size, + connected_workbooks_count, + favorites_total, + has_alert, + is_published, + server_name, + project, + owner, ) = self._parse_element(datasource_xml, ns) self._set_values( ask_data_enablement, @@ -331,6 +397,13 @@ def _parse_common_elements(self, datasource_xml, ns): use_remote_query_agent, webpage_url, size, + connected_workbooks_count, + favorites_total, + has_alert, + is_published, + server_name, + project, + owner, ) return self @@ -355,6 +428,13 @@ def _set_values( use_remote_query_agent, webpage_url, size, + connected_workbooks_count, + favorites_total, + has_alert, + is_published, + server_name, + project, + owner, ): if ask_data_enablement is not None: self._ask_data_enablement = ask_data_enablement @@ -394,6 +474,20 @@ def _set_values( self._webpage_url = webpage_url if size is not None: self._size = int(size) + if connected_workbooks_count is not None: + self._connected_workbooks_count = connected_workbooks_count + if favorites_total is not None: + self._favorites_total = favorites_total + if has_alert is not None: + self._has_alert = has_alert + if is_published is not None: + self._is_published = is_published + if server_name is not None: + self._server_name = server_name + if project is not None: + self._project = project + if owner is not None: + self._owner = owner @classmethod def from_response(cls, resp: str, ns: dict) -> list["DatasourceItem"]: @@ -428,6 +522,11 @@ def _parse_element(datasource_xml: ET.Element, ns: dict) -> tuple: use_remote_query_agent = datasource_xml.get("useRemoteQueryAgent", None) webpage_url = datasource_xml.get("webpageUrl", None) size = datasource_xml.get("size", None) + connected_workbooks_count = nullable_str_to_int(datasource_xml.get("connectedWorkbooksCount", None)) + favorites_total = nullable_str_to_int(datasource_xml.get("favoritesTotal", None)) + has_alert = nullable_str_to_bool(datasource_xml.get("hasAlert", None)) + is_published = nullable_str_to_bool(datasource_xml.get("isPublished", None)) + server_name = datasource_xml.get("serverName", None) tags = None tags_elem = datasource_xml.find(".//t:tags", namespaces=ns) @@ -438,12 +537,14 @@ def _parse_element(datasource_xml: ET.Element, ns: dict) -> tuple: project_name = None project_elem = datasource_xml.find(".//t:project", namespaces=ns) if project_elem is not None: + project = ProjectItem.from_xml(project_elem, ns) project_id = project_elem.get("id", None) project_name = project_elem.get("name", None) owner_id = None owner_elem = datasource_xml.find(".//t:owner", namespaces=ns) if owner_elem is not None: + owner = UserItem.from_xml(owner_elem, ns) owner_id = owner_elem.get("id", None) ask_data_enablement = None @@ -471,4 +572,11 @@ def _parse_element(datasource_xml: ET.Element, ns: dict) -> tuple: use_remote_query_agent, webpage_url, size, + connected_workbooks_count, + favorites_total, + has_alert, + is_published, + server_name, + project, + owner, ) diff --git a/tableauserverclient/models/extract_item.py b/tableauserverclient/models/extract_item.py new file mode 100644 index 000000000..7562ffdde --- /dev/null +++ b/tableauserverclient/models/extract_item.py @@ -0,0 +1,82 @@ +from typing import Optional, List +from defusedxml.ElementTree import fromstring +import xml.etree.ElementTree as ET + + +class ExtractItem: + """ + An extract refresh task item. + + Attributes + ---------- + id : str + The ID of the extract refresh task + priority : int + The priority of the task + type : str + The type of extract refresh (incremental or full) + workbook_id : str, optional + The ID of the workbook if this is a workbook extract + datasource_id : str, optional + The ID of the datasource if this is a datasource extract + """ + + def __init__( + self, priority: int, refresh_type: str, workbook_id: Optional[str] = None, datasource_id: Optional[str] = None + ): + self._id: Optional[str] = None + self._priority = priority + self._type = refresh_type + self._workbook_id = workbook_id + self._datasource_id = datasource_id + + @property + def id(self) -> Optional[str]: + return self._id + + @property + def priority(self) -> int: + return self._priority + + @property + def type(self) -> str: + return self._type + + @property + def workbook_id(self) -> Optional[str]: + return self._workbook_id + + @property + def datasource_id(self) -> Optional[str]: + return self._datasource_id + + @classmethod + def from_response(cls, resp: str, ns: dict) -> List["ExtractItem"]: + """Create ExtractItem objects from XML response.""" + parsed_response = fromstring(resp) + return cls.from_xml_element(parsed_response, ns) + + @classmethod + def from_xml_element(cls, parsed_response: ET.Element, ns: dict) -> List["ExtractItem"]: + """Create ExtractItem objects from XML element.""" + all_extract_items = [] + all_extract_xml = parsed_response.findall(".//t:extract", namespaces=ns) + + for extract_xml in all_extract_xml: + extract_id = extract_xml.get("id", None) + priority = int(extract_xml.get("priority", 0)) + refresh_type = extract_xml.get("type", "") + + # Check for workbook or datasource + workbook_elem = extract_xml.find(".//t:workbook", namespaces=ns) + datasource_elem = extract_xml.find(".//t:datasource", namespaces=ns) + + workbook_id = workbook_elem.get("id", None) if workbook_elem is not None else None + datasource_id = datasource_elem.get("id", None) if datasource_elem is not None else None + + extract_item = cls(priority, refresh_type, workbook_id, datasource_id) + extract_item._id = extract_id + + all_extract_items.append(extract_item) + + return all_extract_items diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index 0083776bb..063897e41 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -146,8 +146,8 @@ def _set_connections(self, connections): def _set_permissions(self, permissions): self._permissions = permissions - def _set_data_quality_warnings(self, dqws): - self._data_quality_warnings = dqws + def _set_data_quality_warnings(self, dqw): + self._data_quality_warnings = dqw def _parse_common_elements(self, flow_xml, ns): if not isinstance(flow_xml, ET.Element): diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index 0afd5582c..00f35e518 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -44,6 +44,11 @@ class GroupItem: login to a site. When the mode is onSync, a license is granted for group members each time the domain is synced. + Attributes + ---------- + user_count: Optional[int] + The number of users in the group. + Examples -------- >>> # Create a new group item @@ -65,6 +70,7 @@ def __init__(self, name=None, domain_name=None) -> None: self._users: Optional[Callable[..., "Pager"]] = None self.name: Optional[str] = name self.domain_name: Optional[str] = domain_name + self._user_count: Optional[int] = None def __repr__(self): return f"{self.__class__.__name__}({self.__dict__!r})" @@ -118,6 +124,10 @@ def users(self) -> "Pager": def _set_users(self, users: Callable[..., "Pager"]) -> None: self._users = users + @property + def user_count(self) -> Optional[int]: + return self._user_count + @classmethod def from_response(cls, resp, ns) -> list["GroupItem"]: all_group_items = list() @@ -127,6 +137,7 @@ def from_response(cls, resp, ns) -> list["GroupItem"]: name = group_xml.get("name", None) group_item = cls(name) group_item._id = group_xml.get("id", None) + group_item._user_count = int(count) if (count := group_xml.get("userCount", None)) else None # Domain name is returned in a domain element for some calls domain_elem = group_xml.find(".//t:domain", namespaces=ns) diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index d7cf891cc..14cec1878 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -2,6 +2,13 @@ class IntervalItem: + """ + This class sets the frequency and start time of the scheduled item. This + class contains the classes for the hourly, daily, weekly, and monthly + intervals. This class mirrors the options you can set using the REST API and + the Tableau Server interface. + """ + class Frequency: Hourly = "Hourly" Daily = "Daily" @@ -26,6 +33,19 @@ class Day: class HourlyInterval: + """ + Runs scheduled item hourly. To set the hourly interval, you create an + instance of the HourlyInterval class and assign the following values: + start_time, end_time, and interval_value. To set the start_time and + end_time, assign the time value using this syntax: start_time=time(hour, minute) + and end_time=time(hour, minute). The hour is specified in 24 hour time. + The interval_value specifies how often the to run the task within the + start and end time. The options are expressed in hours. For example, + interval_value=.25 is every 15 minutes. The values are .25, .5, 1, 2, 4, 6, + 8, 12. Hourly schedules that run more frequently than every 60 minutes must + have start and end times that are on the hour. + """ + def __init__(self, start_time, end_time, interval_value): self.start_time = start_time self.end_time = end_time @@ -109,6 +129,12 @@ def _interval_type_pairs(self): class DailyInterval: + """ + Runs the scheduled item daily. To set the daily interval, you create an + instance of the DailyInterval and assign the start_time. The start time uses + the syntax start_time=time(hour, minute). + """ + def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values @@ -177,6 +203,15 @@ def _interval_type_pairs(self): class WeeklyInterval: + """ + Runs the scheduled item once a week. To set the weekly interval, you create + an instance of the WeeklyInterval and assign the start time and multiple + instances for the interval_value (days of week and start time). The start + time uses the syntax time(hour, minute). The interval_value is the day of + the week, expressed as a IntervalItem. For example + TSC.IntervalItem.Day.Monday for Monday. + """ + def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values @@ -214,6 +249,11 @@ def _interval_type_pairs(self): class MonthlyInterval: + """ + Runs the scheduled item once a month. To set the monthly interval, you + create an instance of the MonthlyInterval and assign the start time and day. + """ + def __init__(self, start_time, interval_value): self.start_time = start_time diff --git a/tableauserverclient/models/location_item.py b/tableauserverclient/models/location_item.py new file mode 100644 index 000000000..fa7c2ff2c --- /dev/null +++ b/tableauserverclient/models/location_item.py @@ -0,0 +1,53 @@ +from typing import Optional +import xml.etree.ElementTree as ET + + +class LocationItem: + """ + Details of where an item is located, such as a personal space or project. + + Attributes + ---------- + id : str | None + The ID of the location. + + type : str | None + The type of location, such as PersonalSpace or Project. + + name : str | None + The name of the location. + """ + + class Type: + PersonalSpace = "PersonalSpace" + Project = "Project" + + def __init__(self): + self._id: Optional[str] = None + self._type: Optional[str] = None + self._name: Optional[str] = None + + def __repr__(self): + return f"{self.__class__.__name__}({self.__dict__!r})" + + @property + def id(self) -> Optional[str]: + return self._id + + @property + def type(self) -> Optional[str]: + return self._type + + @property + def name(self) -> Optional[str]: + return self._name + + @classmethod + def from_xml(cls, xml: ET.Element, ns: Optional[dict] = None) -> "LocationItem": + if ns is None: + ns = {} + location = cls() + location._id = xml.get("id", None) + location._type = xml.get("type", None) + location._name = xml.get("name", None) + return location diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 9be1196ba..1ab369ba7 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -1,11 +1,11 @@ -import logging import xml.etree.ElementTree as ET -from typing import Optional +from typing import Optional, overload from defusedxml.ElementTree import fromstring from tableauserverclient.models.exceptions import UnpopulatedPropertyError -from tableauserverclient.models.property_decorators import property_is_enum, property_not_empty +from tableauserverclient.models.property_decorators import property_is_enum +from tableauserverclient.models.user_item import UserItem class ProjectItem: @@ -39,12 +39,32 @@ class corresponds to the project resources you can access using the Tableau Attributes ---------- + datasource_count : int + The number of data sources in the project. + id : str The unique identifier for the project. + owner: Optional[UserItem] + The UserItem owner of the project. + owner_id : str The unique identifier for the UserItem owner of the project. + project_count : int + The number of projects in the project. + + top_level_project : bool + True if the project is a top-level project. + + view_count : int + The number of views in the project. + + workbook_count : int + The number of workbooks in the project. + + writeable : bool + True if the project is writeable. """ ERROR_MSG = "Project item must be populated with permissions first." @@ -75,6 +95,8 @@ def __init__( self.parent_id: Optional[str] = parent_id self._samples: Optional[bool] = samples self._owner_id: Optional[str] = None + self._top_level_project: Optional[bool] = None + self._writeable: Optional[bool] = None self._permissions = None self._default_workbook_permissions = None @@ -87,6 +109,13 @@ def __init__( self._default_database_permissions = None self._default_table_permissions = None + self._project_count: Optional[int] = None + self._workbook_count: Optional[int] = None + self._view_count: Optional[int] = None + self._datasource_count: Optional[int] = None + + self._owner: Optional[UserItem] = None + @property def content_permissions(self): return self._content_permissions @@ -176,25 +205,53 @@ def owner_id(self) -> Optional[str]: def owner_id(self, value: str) -> None: self._owner_id = value + @property + def top_level_project(self) -> Optional[bool]: + return self._top_level_project + + @property + def writeable(self) -> Optional[bool]: + return self._writeable + + @property + def project_count(self) -> Optional[int]: + return self._project_count + + @property + def workbook_count(self) -> Optional[int]: + return self._workbook_count + + @property + def view_count(self) -> Optional[int]: + return self._view_count + + @property + def datasource_count(self) -> Optional[int]: + return self._datasource_count + + @property + def owner(self) -> Optional[UserItem]: + return self._owner + def is_default(self): return self.name.lower() == "default" - def _parse_common_tags(self, project_xml, ns): - if not isinstance(project_xml, ET.Element): - project_xml = fromstring(project_xml).find(".//t:project", namespaces=ns) - - if project_xml is not None: - ( - _, - name, - description, - content_permissions, - parent_id, - ) = self._parse_element(project_xml) - self._set_values(None, name, description, content_permissions, parent_id) - return self - - def _set_values(self, project_id, name, description, content_permissions, parent_id, owner_id): + def _set_values( + self, + project_id, + name, + description, + content_permissions, + parent_id, + owner_id, + top_level_project, + writeable, + project_count, + workbook_count, + view_count, + datasource_count, + owner, + ): if project_id is not None: self._id = project_id if name: @@ -207,6 +264,20 @@ def _set_values(self, project_id, name, description, content_permissions, parent self.parent_id = parent_id if owner_id: self._owner_id = owner_id + if project_count is not None: + self._project_count = project_count + if workbook_count is not None: + self._workbook_count = workbook_count + if view_count is not None: + self._view_count = view_count + if datasource_count is not None: + self._datasource_count = datasource_count + if top_level_project is not None: + self._top_level_project = top_level_project + if writeable is not None: + self._writeable = writeable + if owner is not None: + self._owner = owner def _set_permissions(self, permissions): self._permissions = permissions @@ -220,31 +291,71 @@ def _set_default_permissions(self, permissions, content_type): ) @classmethod - def from_response(cls, resp, ns) -> list["ProjectItem"]: + def from_response(cls, resp: bytes, ns: Optional[dict]) -> list["ProjectItem"]: all_project_items = list() parsed_response = fromstring(resp) all_project_xml = parsed_response.findall(".//t:project", namespaces=ns) for project_xml in all_project_xml: - project_item = cls.from_xml(project_xml) + project_item = cls.from_xml(project_xml, namespace=ns) all_project_items.append(project_item) return all_project_items @classmethod - def from_xml(cls, project_xml, namespace=None) -> "ProjectItem": + def from_xml(cls, project_xml: ET.Element, namespace: Optional[dict] = None) -> "ProjectItem": project_item = cls() - project_item._set_values(*cls._parse_element(project_xml)) + project_item._set_values(*cls._parse_element(project_xml, namespace)) return project_item @staticmethod - def _parse_element(project_xml): + def _parse_element(project_xml: ET.Element, namespace: Optional[dict]) -> tuple: id = project_xml.get("id", None) name = project_xml.get("name", None) description = project_xml.get("description", None) content_permissions = project_xml.get("contentPermissions", None) parent_id = project_xml.get("parentProjectId", None) + top_level_project = str_to_bool(project_xml.get("topLevelProject", None)) + writeable = str_to_bool(project_xml.get("writeable", None)) owner_id = None - for owner in project_xml: - owner_id = owner.get("id", None) + owner = None + if (owner_elem := project_xml.find(".//t:owner", namespaces=namespace)) is not None: + owner = UserItem.from_xml(owner_elem, namespace) + owner_id = owner_elem.get("id", None) + + project_count = None + workbook_count = None + view_count = None + datasource_count = None + if (count_elem := project_xml.find(".//t:contentsCounts", namespaces=namespace)) is not None: + project_count = int(count_elem.get("projectCount", 0)) + workbook_count = int(count_elem.get("workbookCount", 0)) + view_count = int(count_elem.get("viewCount", 0)) + datasource_count = int(count_elem.get("dataSourceCount", 0)) + + return ( + id, + name, + description, + content_permissions, + parent_id, + owner_id, + top_level_project, + writeable, + project_count, + workbook_count, + view_count, + datasource_count, + owner, + ) + + +@overload +def str_to_bool(value: str) -> bool: ... + + +@overload +def str_to_bool(value: None) -> None: ... + - return id, name, description, content_permissions, parent_id, owner_id +def str_to_bool(value): + return value.lower() == "true" if value is not None else None diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index e39042058..a2118e3d6 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -20,6 +20,63 @@ class ScheduleItem: + """ + Using the TSC library, you can schedule extract refresh or subscription + tasks on Tableau Server. You can also get and update information about the + scheduled tasks, or delete scheduled tasks. + + If you have the identifier of the job, you can use the TSC library to find + out the status of the asynchronous job. + + The schedule properties are defined in the ScheduleItem class. The class + corresponds to the properties for schedules you can access in Tableau + Server or by using the Tableau Server REST API. The Schedule methods are + based upon the endpoints for jobs in the REST API and operate on the JobItem + class. + + Parameters + ---------- + name : str + The name of the schedule. + + priority : int + The priority of the schedule. Lower values represent higher priority, + with 0 indicating the highest priority. + + schedule_type : str + The type of task schedule. See ScheduleItem.Type for the possible values. + + execution_order : str + Specifies how the scheduled tasks should run. The choices are Parallel + which uses all avaiable background processes for a scheduled task, or + Serial, which limits the schedule to one background process. + + interval_item : Interval + Specifies the frequency that the scheduled task should run. The + interval_item is an instance of the IntervalItem class. The + interval_item has properties for frequency (hourly, daily, weekly, + monthly), and what time and date the scheduled item runs. You set this + value by declaring an IntervalItem object that is one of the following: + HourlyInterval, DailyInterval, WeeklyInterval, or MonthlyInterval. + + Attributes + ---------- + created_at : datetime + The date and time the schedule was created. + + end_schedule_at : datetime + The date and time the schedule ends. + + id : str + The unique identifier for the schedule. + + next_run_at : datetime + The date and time the schedule is next run. + + state : str + The state of the schedule. See ScheduleItem.State for the possible values. + """ + class Type: Extract = "Extract" Flow = "Flow" diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index e4e146f9c..ab65b97b5 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -1188,6 +1188,34 @@ def _parse_element(site_xml, ns): ) +class SiteAuthConfiguration: + """ + Authentication configuration for a site. + """ + + def __init__(self): + self.auth_setting: Optional[str] = None + self.enabled: Optional[bool] = None + self.idp_configuration_id: Optional[str] = None + self.idp_configuration_name: Optional[str] = None + self.known_provider_alias: Optional[str] = None + + @classmethod + def from_response(cls, resp: bytes, ns: dict) -> list["SiteAuthConfiguration"]: + all_auth_configs = list() + parsed_response = fromstring(resp) + all_auth_xml = parsed_response.findall(".//t:siteAuthConfiguration", namespaces=ns) + for auth_xml in all_auth_xml: + auth_config = cls() + auth_config.auth_setting = auth_xml.get("authSetting", None) + auth_config.enabled = string_to_bool(auth_xml.get("enabled", "")) + auth_config.idp_configuration_id = auth_xml.get("idpConfigurationId", None) + auth_config.idp_configuration_name = auth_xml.get("idpConfigurationName", None) + auth_config.known_provider_alias = auth_xml.get("knownProviderAlias", None) + all_auth_configs.append(auth_config) + return all_auth_configs + + # Used to convert string represented boolean to a boolean type def string_to_bool(s: str) -> bool: return s.lower() == "true" diff --git a/tableauserverclient/models/table_item.py b/tableauserverclient/models/table_item.py index 0afdd4df3..541f84360 100644 --- a/tableauserverclient/models/table_item.py +++ b/tableauserverclient/models/table_item.py @@ -1,8 +1,12 @@ +from typing import Callable, Optional, TYPE_CHECKING from defusedxml.ElementTree import fromstring from .exceptions import UnpopulatedPropertyError from .property_decorators import property_not_empty, property_is_boolean +if TYPE_CHECKING: + from tableauserverclient.models import DQWItem + class TableItem: def __init__(self, name, description=None): @@ -40,7 +44,7 @@ def dqws(self): return self._data_quality_warnings() @property - def id(self): + def id(self) -> Optional[str]: return self._id @property @@ -100,8 +104,8 @@ def columns(self): def _set_columns(self, columns): self._columns = columns - def _set_data_quality_warnings(self, dqws): - self._data_quality_warnings = dqws + def _set_data_quality_warnings(self, dqw: Callable[[], list["DQWItem"]]) -> None: + self._data_quality_warnings = dqw def _set_values(self, table_values): if "id" in table_values: diff --git a/tableauserverclient/models/tableau_types.py b/tableauserverclient/models/tableau_types.py index 01ee3d3a9..e69d02a06 100644 --- a/tableauserverclient/models/tableau_types.py +++ b/tableauserverclient/models/tableau_types.py @@ -1,8 +1,10 @@ from typing import Union +from tableauserverclient.models.database_item import DatabaseItem from tableauserverclient.models.datasource_item import DatasourceItem from tableauserverclient.models.flow_item import FlowItem from tableauserverclient.models.project_item import ProjectItem +from tableauserverclient.models.table_item import TableItem from tableauserverclient.models.view_item import ViewItem from tableauserverclient.models.workbook_item import WorkbookItem from tableauserverclient.models.metric_item import MetricItem @@ -25,7 +27,17 @@ class Resource: # resource types that have permissions, can be renamed, etc # todo: refactoring: should actually define TableauItem as an interface and let all these implement it -TableauItem = Union[DatasourceItem, FlowItem, MetricItem, ProjectItem, ViewItem, WorkbookItem, VirtualConnectionItem] +TableauItem = Union[ + DatasourceItem, + FlowItem, + MetricItem, + ProjectItem, + ViewItem, + WorkbookItem, + VirtualConnectionItem, + DatabaseItem, + TableItem, +] def plural_type(content_type: Union[Resource, str]) -> str: diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 365e44c1d..c995b4e07 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -7,6 +7,7 @@ from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime +from tableauserverclient.models.site_item import SiteAuthConfiguration from .exceptions import UnpopulatedPropertyError from .property_decorators import ( property_is_enum, @@ -37,6 +38,49 @@ class UserItem: auth_setting: str Required attribute for Tableau Cloud. How the user autenticates to the server. + + Attributes + ---------- + domain_name: Optional[str] + The name of the Active Directory domain ("local" if local authentication + is used). + + email: Optional[str] + The email address of the user. + + external_auth_user_id: Optional[str] + The unique identifier for the user in the external authentication system. + + id: Optional[str] + The unique identifier for the user. + + favorites: dict[str, list] + The favorites of the user. Must be populated with a call to + `populate_favorites()`. + + fullname: Optional[str] + The full name of the user. + + groups: Pager + The groups the user belongs to. Must be populated with a call to + `populate_groups()`. + + last_login: Optional[datetime] + The last time the user logged in. + + locale: Optional[str] + The locale of the user. + + language: Optional[str] + Language setting for the user. + + idp_configuration_id: Optional[str] + The ID of the identity provider configuration. + + workbooks: Pager + The workbooks owned by the user. Must be populated with a call to + `populate_workbooks()`. + """ tag_name: str = "user" @@ -94,6 +138,9 @@ def __init__( self.name: Optional[str] = name self.site_role: Optional[str] = site_role self.auth_setting: Optional[str] = auth_setting + self._locale: Optional[str] = None + self._language: Optional[str] = None + self._idp_configuration_id: Optional[str] = None return None @@ -184,6 +231,26 @@ def groups(self) -> "Pager": raise UnpopulatedPropertyError(error) return self._groups() + @property + def locale(self) -> Optional[str]: + return self._locale + + @property + def language(self) -> Optional[str]: + return self._language + + @property + def idp_configuration_id(self) -> Optional[str]: + """ + IDP configuration id for the user. This is only available on Tableau + Cloud, 3.24 or later + """ + return self._idp_configuration_id + + @idp_configuration_id.setter + def idp_configuration_id(self, value: str) -> None: + self._idp_configuration_id = value + def _set_workbooks(self, workbooks) -> None: self._workbooks = workbooks @@ -204,8 +271,11 @@ def _parse_common_tags(self, user_xml, ns) -> "UserItem": email, auth_setting, _, + _, + _, + _, ) = self._parse_element(user_xml, ns) - self._set_values(None, None, site_role, None, None, fullname, email, auth_setting, None) + self._set_values(None, None, site_role, None, None, fullname, email, auth_setting, None, None, None, None) return self def _set_values( @@ -219,6 +289,9 @@ def _set_values( email, auth_setting, domain_name, + locale, + language, + idp_configuration_id, ): if id is not None: self._id = id @@ -238,6 +311,12 @@ def _set_values( self._auth_setting = auth_setting if domain_name: self._domain_name = domain_name + if locale: + self._locale = locale + if language: + self._language = language + if idp_configuration_id: + self._idp_configuration_id = idp_configuration_id @classmethod def from_response(cls, resp, ns) -> list["UserItem"]: @@ -249,6 +328,12 @@ def from_response_as_owner(cls, resp, ns) -> list["UserItem"]: element_name = ".//t:owner" return cls._parse_xml(element_name, resp, ns) + @classmethod + def from_xml(cls, xml: ET.Element, ns: Optional[dict] = None) -> "UserItem": + item = cls() + item._set_values(*cls._parse_element(xml, ns)) + return item + @classmethod def _parse_xml(cls, element_name, resp, ns): all_user_items = [] @@ -265,6 +350,9 @@ def _parse_xml(cls, element_name, resp, ns): email, auth_setting, domain_name, + locale, + language, + idp_configuration_id, ) = cls._parse_element(user_xml, ns) user_item = cls(name, site_role) user_item._set_values( @@ -277,6 +365,9 @@ def _parse_xml(cls, element_name, resp, ns): email, auth_setting, domain_name, + locale, + language, + idp_configuration_id, ) all_user_items.append(user_item) return all_user_items @@ -295,6 +386,9 @@ def _parse_element(user_xml, ns): fullname = user_xml.get("fullName", None) email = user_xml.get("email", None) auth_setting = user_xml.get("authSetting", None) + locale = user_xml.get("locale", None) + language = user_xml.get("language", None) + idp_configuration_id = user_xml.get("idpConfigurationId", None) domain_name = None domain_elem = user_xml.find(".//t:domain", namespaces=ns) @@ -311,6 +405,9 @@ def _parse_element(user_xml, ns): email, auth_setting, domain_name, + locale, + language, + idp_configuration_id, ) class CSVImport: @@ -361,6 +458,9 @@ def create_user_from_line(line: str): values[UserItem.CSVImport.ColumnType.EMAIL], values[UserItem.CSVImport.ColumnType.AUTH], None, + None, + None, + None, ) return user diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index 88cec7328..dc8eda9c8 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -1,15 +1,21 @@ import copy from datetime import datetime from requests import Response -from typing import Callable, Optional +from typing import TYPE_CHECKING, Callable, Optional, overload from collections.abc import Iterator from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime from tableauserverclient.models.exceptions import UnpopulatedPropertyError +from tableauserverclient.models.location_item import LocationItem from tableauserverclient.models.permissions_item import PermissionsRule +from tableauserverclient.models.project_item import ProjectItem from tableauserverclient.models.tag_item import TagItem +from tableauserverclient.models.user_item import UserItem + +if TYPE_CHECKING: + from tableauserverclient.models.workbook_item import WorkbookItem class ViewItem: @@ -34,9 +40,16 @@ class ViewItem: The image of the view. You must first call the `views.populate_image` method to access the image. + location: Optional[LocationItem], default None + The location of the view. The location can be a personal space or a + project. + name: Optional[str], default None The name of the view. + owner: Optional[UserItem], default None + The owner of the view. + owner_id: Optional[str], default None The ID for the owner of the view. @@ -48,6 +61,9 @@ class ViewItem: The preview image of the view. You must first call the `views.populate_preview_image` method to access the preview image. + project: Optional[ProjectItem], default None + The project that contains the view. + project_id: Optional[str], default None The ID for the project that contains the view. @@ -60,9 +76,11 @@ class ViewItem: updated_at: Optional[datetime], default None The date and time when the view was last updated. + workbook: Optional[WorkbookItem], default None + The workbook that contains the view. + workbook_id: Optional[str], default None The ID for the workbook that contains the view. - """ def __init__(self) -> None: @@ -84,11 +102,18 @@ def __init__(self) -> None: self._workbook_id: Optional[str] = None self._permissions: Optional[Callable[[], list[PermissionsRule]]] = None self.tags: set[str] = set() + self._favorites_total: Optional[int] = None + self._view_url_name: Optional[str] = None self._data_acceleration_config = { "acceleration_enabled": None, "acceleration_status": None, } + self._owner: Optional[UserItem] = None + self._project: Optional[ProjectItem] = None + self._workbook: Optional["WorkbookItem"] = None + self._location: Optional[LocationItem] = None + def __str__(self): return "".format( self._id, self.name, self.content_url, self.project_id @@ -190,6 +215,14 @@ def updated_at(self) -> Optional[datetime]: def workbook_id(self) -> Optional[str]: return self._workbook_id + @property + def view_url_name(self) -> Optional[str]: + return self._view_url_name + + @property + def favorites_total(self) -> Optional[int]: + return self._favorites_total + @property def data_acceleration_config(self): return self._data_acceleration_config @@ -198,6 +231,22 @@ def data_acceleration_config(self): def data_acceleration_config(self, value): self._data_acceleration_config = value + @property + def project(self) -> Optional["ProjectItem"]: + return self._project + + @property + def workbook(self) -> Optional["WorkbookItem"]: + return self._workbook + + @property + def owner(self) -> Optional[UserItem]: + return self._owner + + @property + def location(self) -> Optional[LocationItem]: + return self._location + @property def permissions(self) -> list[PermissionsRule]: if self._permissions is None: @@ -228,7 +277,7 @@ def from_xml(cls, view_xml, ns, workbook_id="") -> "ViewItem": workbook_elem = view_xml.find(".//t:workbook", namespaces=ns) 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) + 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)) @@ -236,22 +285,35 @@ def from_xml(cls, view_xml, ns, workbook_id="") -> "ViewItem": view_item._name = view_xml.get("name", None) view_item._content_url = view_xml.get("contentUrl", None) view_item._sheet_type = view_xml.get("sheetType", None) + view_item._favorites_total = string_to_int(view_xml.get("favoritesTotal", None)) + view_item._view_url_name = view_xml.get("viewUrlName", None) if usage_elem is not None: total_view = usage_elem.get("totalViewCount", None) if total_view: view_item._total_views = int(total_view) if owner_elem is not None: + user = UserItem.from_xml(owner_elem, ns) + view_item._owner = user view_item._owner_id = owner_elem.get("id", None) if project_elem is not None: - view_item._project_id = project_elem.get("id", None) + project_item = ProjectItem.from_xml(project_elem, ns) + view_item._project = project_item + view_item._project_id = project_item.id if workbook_id: view_item._workbook_id = workbook_id elif workbook_elem is not None: - view_item._workbook_id = workbook_elem.get("id", None) + from tableauserverclient.models.workbook_item import WorkbookItem + + workbook_item = WorkbookItem.from_xml(workbook_elem, ns) + view_item._workbook = workbook_item + view_item._workbook_id = workbook_item.id if tags_elem is not None: tags = TagItem.from_xml_element(tags_elem, ns) view_item.tags = tags view_item._initial_tags = copy.copy(tags) + if (location_elem := view_xml.find(".//t:location", namespaces=ns)) is not None: + location = LocationItem.from_xml(location_elem, ns) + view_item._location = location 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 @@ -274,3 +336,15 @@ def parse_data_acceleration_config(data_acceleration_elem): def string_to_bool(s: str) -> bool: return s.lower() == "true" + + +@overload +def string_to_int(s: None) -> None: ... + + +@overload +def string_to_int(s: str) -> int: ... + + +def string_to_int(s): + return int(s) if s is not None else None diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 32ab413a4..a3ede65d6 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -2,11 +2,14 @@ import datetime import uuid import xml.etree.ElementTree as ET -from typing import Callable, Optional +from typing import Callable, Optional, overload from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime +from tableauserverclient.models.location_item import LocationItem +from tableauserverclient.models.project_item import ProjectItem +from tableauserverclient.models.user_item import UserItem from .connection_item import ConnectionItem from .exceptions import UnpopulatedPropertyError from .permissions_item import PermissionsRule @@ -51,13 +54,31 @@ class as arguments. The workbook item specifies the project. created_at : Optional[datetime.datetime] The date and time the workbook was created. + default_view_id : Optional[str] + The identifier for the default view of the workbook. + description : Optional[str] User-defined description of the workbook. + encrypt_extracts : Optional[bool] + Indicates whether extracts are encrypted. + + has_extracts : Optional[bool] + Indicates whether the workbook has extracts. + id : Optional[str] The identifier for the workbook. You need this value to query a specific workbook or to delete a workbook with the get_by_id and delete methods. + last_published_at : Optional[datetime.datetime] + The date and time the workbook was last published. + + location : Optional[LocationItem] + The location of the workbook, such as a personal space or project. + + owner : Optional[UserItem] + The owner of the workbook. + owner_id : Optional[str] The identifier for the owner (UserItem) of the workbook. @@ -65,6 +86,9 @@ class as arguments. The workbook item specifies the project. The thumbnail image for the view. You must first call the workbooks.populate_preview_image method to access this data. + project: Optional[ProjectItem] + The project that contains the workbook. + project_name : Optional[str] The name of the project that contains the workbook. @@ -139,6 +163,15 @@ def __init__( self._permissions = None self.thumbnails_user_id = thumbnails_user_id self.thumbnails_group_id = thumbnails_group_id + self._sheet_count: Optional[int] = None + self._has_extracts: Optional[bool] = None + self._project: Optional[ProjectItem] = None + self._owner: Optional[UserItem] = None + self._location: Optional[LocationItem] = None + self._encrypt_extracts: Optional[bool] = None + self._default_view_id: Optional[str] = None + self._share_description: Optional[str] = None + self._last_published_at: Optional[datetime.datetime] = None return None @@ -234,6 +267,14 @@ def show_tabs(self, value: bool): def size(self): return self._size + @property + def sheet_count(self) -> Optional[int]: + return self._sheet_count + + @property + def has_extracts(self) -> Optional[bool]: + return self._has_extracts + @property def updated_at(self) -> Optional[datetime.datetime]: return self._updated_at @@ -300,6 +341,34 @@ def thumbnails_group_id(self) -> Optional[str]: def thumbnails_group_id(self, value: str): self._thumbnails_group_id = value + @property + def project(self) -> Optional[ProjectItem]: + return self._project + + @property + def owner(self) -> Optional[UserItem]: + return self._owner + + @property + def location(self) -> Optional[LocationItem]: + return self._location + + @property + def encrypt_extracts(self) -> Optional[bool]: + return self._encrypt_extracts + + @property + def default_view_id(self) -> Optional[str]: + return self._default_view_id + + @property + def share_description(self) -> Optional[str]: + return self._share_description + + @property + def last_published_at(self) -> Optional[datetime.datetime]: + return self._last_published_at + def _set_connections(self, connections): self._connections = connections @@ -342,6 +411,15 @@ def _parse_common_tags(self, workbook_xml, ns): views, data_acceleration_config, data_freshness_policy, + sheet_count, + has_extracts, + project, + owner, + location, + encrypt_extracts, + default_view_id, + share_description, + last_published_at, ) = self._parse_element(workbook_xml, ns) self._set_values( @@ -361,6 +439,15 @@ def _parse_common_tags(self, workbook_xml, ns): views, data_acceleration_config, data_freshness_policy, + sheet_count, + has_extracts, + project, + owner, + location, + encrypt_extracts, + default_view_id, + share_description, + last_published_at, ) return self @@ -383,6 +470,15 @@ def _set_values( views, data_acceleration_config, data_freshness_policy, + sheet_count, + has_extracts, + project, + owner, + location, + encrypt_extracts, + default_view_id, + share_description, + last_published_at, ): if id is not None: self._id = id @@ -417,6 +513,24 @@ def _set_values( self.data_acceleration_config = data_acceleration_config if data_freshness_policy is not None: self.data_freshness_policy = data_freshness_policy + if sheet_count is not None: + self._sheet_count = sheet_count + if has_extracts is not None: + self._has_extracts = has_extracts + if project: + self._project = project + if owner: + self._owner = owner + if location: + self._location = location + if encrypt_extracts is not None: + self._encrypt_extracts = encrypt_extracts + if default_view_id is not None: + self._default_view_id = default_view_id + if share_description is not None: + self._share_description = share_description + if last_published_at is not None: + self._last_published_at = last_published_at @classmethod def from_response(cls, resp: str, ns: dict[str, str]) -> list["WorkbookItem"]: @@ -443,6 +557,12 @@ def _parse_element(workbook_xml, ns): created_at = parse_datetime(workbook_xml.get("createdAt", None)) description = workbook_xml.get("description", None) updated_at = parse_datetime(workbook_xml.get("updatedAt", None)) + sheet_count = string_to_int(workbook_xml.get("sheetCount", None)) + has_extracts = string_to_bool(workbook_xml.get("hasExtracts", "")) + encrypt_extracts = string_to_bool(e) if (e := workbook_xml.get("encryptExtracts", None)) is not None else None + default_view_id = workbook_xml.get("defaultViewId", None) + share_description = workbook_xml.get("shareDescription", None) + last_published_at = parse_datetime(workbook_xml.get("lastPublishedAt", None)) size = workbook_xml.get("size", None) if size: @@ -452,14 +572,18 @@ def _parse_element(workbook_xml, ns): project_id = None project_name = None + project = None project_tag = workbook_xml.find(".//t:project", namespaces=ns) if project_tag is not None: + project = ProjectItem.from_xml(project_tag, ns) project_id = project_tag.get("id", None) project_name = project_tag.get("name", None) owner_id = None + owner = None owner_tag = workbook_xml.find(".//t:owner", namespaces=ns) if owner_tag is not None: + owner = UserItem.from_xml(owner_tag, ns) owner_id = owner_tag.get("id", None) tags = None @@ -473,6 +597,11 @@ def _parse_element(workbook_xml, ns): if views_elem is not None: views = ViewItem.from_xml_element(views_elem, ns) + location = None + location_elem = workbook_xml.find(".//t:location", namespaces=ns) + if location_elem is not None: + location = LocationItem.from_xml(location_elem, ns) + data_acceleration_config = { "acceleration_enabled": None, "accelerate_now": None, @@ -505,6 +634,15 @@ def _parse_element(workbook_xml, ns): views, data_acceleration_config, data_freshness_policy, + sheet_count, + has_extracts, + project, + owner, + location, + encrypt_extracts, + default_view_id, + share_description, + last_published_at, ) @@ -535,3 +673,15 @@ def parse_data_acceleration_config(data_acceleration_elem): # Used to convert string represented boolean to a boolean type def string_to_bool(s: str) -> bool: return s.lower() == "true" + + +@overload +def string_to_int(s: None) -> None: ... + + +@overload +def string_to_int(s: str) -> int: ... + + +def string_to_int(s): + return int(s) if s is not None else None diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index c0e106eb2..dc88ceaa5 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -1,7 +1,8 @@ import logging -from typing import Union +from typing import TYPE_CHECKING, Optional, Union from collections.abc import Iterable +from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.server.endpoint.default_permissions_endpoint import _DefaultPermissionsEndpoint from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint from tableauserverclient.server.endpoint.endpoint import api, Endpoint @@ -13,6 +14,10 @@ from tableauserverclient.helpers.logging import logger +if TYPE_CHECKING: + from tableauserverclient.models.dqw_item import DQWItem + from tableauserverclient.server.request_options import RequestOptions + class Databases(Endpoint, TaggingMixin): def __init__(self, parent_srv): @@ -23,11 +28,29 @@ def __init__(self, parent_srv): self._data_quality_warnings = _DataQualityWarningEndpoint(parent_srv, Resource.Database) @property - def baseurl(self): + def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/databases" @api(version="3.5") - def get(self, req_options=None): + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[DatabaseItem], PaginationItem]: + """ + Get information about all databases on the site. Endpoint is paginated, + and will return a default of 100 items per page. Use the `req_options` + parameter to customize the request. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_databases + + Parameters + ---------- + req_options : RequestOptions, optional + Options to customize the request. If not provided, defaults to None. + + Returns + ------- + tuple[list[DatabaseItem], PaginationItem] + A tuple containing a list of DatabaseItem objects and a + PaginationItem object. + """ logger.info("Querying all databases on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -37,7 +60,27 @@ def get(self, req_options=None): # Get 1 database @api(version="3.5") - def get_by_id(self, database_id): + def get_by_id(self, database_id: str) -> DatabaseItem: + """ + Get information about a single database asset on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_database + + Parameters + ---------- + database_id : str + The ID of the database to retrieve. + + Returns + ------- + DatabaseItem + A DatabaseItem object representing the database. + + Raises + ------ + ValueError + If the database ID is undefined. + """ if not database_id: error = "database ID undefined." raise ValueError(error) @@ -47,7 +90,24 @@ def get_by_id(self, database_id): return DatabaseItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="3.5") - def delete(self, database_id): + def delete(self, database_id: str) -> None: + """ + Deletes a single database asset from the server. + + Parameters + ---------- + database_id : str + The ID of the database to delete. + + Returns + ------- + None + + Raises + ------ + ValueError + If the database ID is undefined. + """ if not database_id: error = "Database ID undefined." raise ValueError(error) @@ -56,7 +116,28 @@ def delete(self, database_id): logger.info(f"Deleted single database (ID: {database_id})") @api(version="3.5") - def update(self, database_item): + def update(self, database_item: DatabaseItem) -> DatabaseItem: + """ + Update the database description, certify the database, set permissions, + or assign a User as the database contact. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#update_database + + Parameters + ---------- + database_item : DatabaseItem + The DatabaseItem object to update. + + Returns + ------- + DatabaseItem + The updated DatabaseItem object. + + Raises + ------ + MissingRequiredFieldError + If the database item is missing an ID. + """ if not database_item.id: error = "Database item missing ID." raise MissingRequiredFieldError(error) @@ -88,43 +169,45 @@ def _get_tables_for_database(self, database_item): return tables @api(version="3.5") - def populate_permissions(self, item): + def populate_permissions(self, item: DatabaseItem) -> None: self._permissions.populate(item) @api(version="3.5") - def update_permissions(self, item, rules): + def update_permissions(self, item: DatabaseItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: return self._permissions.update(item, rules) @api(version="3.5") - def delete_permission(self, item, rules): + def delete_permission(self, item: DatabaseItem, rules: list[PermissionsRule]) -> None: self._permissions.delete(item, rules) @api(version="3.5") - def populate_table_default_permissions(self, item): + def populate_table_default_permissions(self, item: DatabaseItem): self._default_permissions.populate_default_permissions(item, Resource.Table) @api(version="3.5") - def update_table_default_permissions(self, item): - return self._default_permissions.update_default_permissions(item, Resource.Table) + def update_table_default_permissions( + self, item: DatabaseItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: + return self._default_permissions.update_default_permissions(item, rules, Resource.Table) @api(version="3.5") - def delete_table_default_permissions(self, item): - self._default_permissions.delete_default_permission(item, Resource.Table) + def delete_table_default_permissions(self, rule: PermissionsRule, item: DatabaseItem) -> None: + self._default_permissions.delete_default_permission(item, rule, Resource.Table) @api(version="3.5") - def populate_dqw(self, item): + def populate_dqw(self, item: DatabaseItem) -> None: self._data_quality_warnings.populate(item) @api(version="3.5") - def update_dqw(self, item, warning): + def update_dqw(self, item: DatabaseItem, warning: "DQWItem") -> list["DQWItem"]: return self._data_quality_warnings.update(item, warning) @api(version="3.5") - def add_dqw(self, item, warning): + def add_dqw(self, item: DatabaseItem, warning: "DQWItem") -> list["DQWItem"]: return self._data_quality_warnings.add(item, warning) @api(version="3.5") - def delete_dqw(self, item): + def delete_dqw(self, item: DatabaseItem) -> None: self._data_quality_warnings.clear(item) @api(version="3.9") diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 69913a724..168446974 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -733,7 +733,7 @@ def populate_dqw(self, item) -> None: self._data_quality_warnings.populate(item) @api(version="3.5") - def update_dqw(self, item, warning): + def update_dqw(self, item: DatasourceItem, warning: "DQWItem") -> list["DQWItem"]: """ Update the warning type, status, and message of a data quality warning. @@ -755,7 +755,7 @@ def update_dqw(self, item, warning): return self._data_quality_warnings.update(item, warning) @api(version="3.5") - def add_dqw(self, item, warning): + def add_dqw(self, item: DatasourceItem, warning: "DQWItem") -> list["DQWItem"]: """ Add a data quality warning to a datasource. @@ -786,7 +786,7 @@ def add_dqw(self, item, warning): return self._data_quality_warnings.add(item, warning) @api(version="3.5") - def delete_dqw(self, item): + def delete_dqw(self, item: DatasourceItem) -> None: """ Delete a data quality warnings from an asset. diff --git a/tableauserverclient/server/endpoint/dqw_endpoint.py b/tableauserverclient/server/endpoint/dqw_endpoint.py index 90e31483b..d2ad517ee 100644 --- a/tableauserverclient/server/endpoint/dqw_endpoint.py +++ b/tableauserverclient/server/endpoint/dqw_endpoint.py @@ -1,4 +1,5 @@ import logging +from typing import Callable, Optional, Protocol, TYPE_CHECKING from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError @@ -7,6 +8,15 @@ from tableauserverclient.helpers.logging import logger +if TYPE_CHECKING: + from tableauserverclient.server.request_options import RequestOptions + + +class HasId(Protocol): + @property + def id(self) -> Optional[str]: ... + def _set_data_quality_warnings(self, dqw: Callable[[], list[DQWItem]]): ... + class _DataQualityWarningEndpoint(Endpoint): def __init__(self, parent_srv, resource_type): @@ -14,12 +24,12 @@ def __init__(self, parent_srv, resource_type): self.resource_type = resource_type @property - def baseurl(self): + def baseurl(self) -> str: return "{}/sites/{}/dataQualityWarnings/{}".format( self.parent_srv.baseurl, self.parent_srv.site_id, self.resource_type ) - def add(self, resource, warning): + def add(self, resource: HasId, warning: DQWItem) -> list[DQWItem]: url = f"{self.baseurl}/{resource.id}" add_req = RequestFactory.DQW.add_req(warning) response = self.post_request(url, add_req) @@ -28,7 +38,7 @@ def add(self, resource, warning): return warnings - def update(self, resource, warning): + def update(self, resource: HasId, warning: DQWItem) -> list[DQWItem]: url = f"{self.baseurl}/{resource.id}" add_req = RequestFactory.DQW.update_req(warning) response = self.put_request(url, add_req) @@ -37,11 +47,11 @@ def update(self, resource, warning): return warnings - def clear(self, resource): + def clear(self, resource: HasId) -> None: url = f"{self.baseurl}/{resource.id}" return self.delete_request(url) - def populate(self, item): + def populate(self, item: HasId) -> None: if not item.id: error = "Server item is missing ID. Item must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -52,7 +62,7 @@ def dqw_fetcher(): item._set_data_quality_warnings(dqw_fetcher) logger.info(f"Populated permissions for item (ID: {item.id})") - def _get_data_quality_warnings(self, item, req_options=None): + def _get_data_quality_warnings(self, item: HasId, req_options: Optional["RequestOptions"] = None) -> list[DQWItem]: url = f"{self.baseurl}/{item.id}" server_response = self.get_request(url, req_options) dqws = DQWItem.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 9e1160705..21462af5f 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -14,6 +14,7 @@ TypeVar, Union, ) +from typing_extensions import Self from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.request_options import RequestOptions @@ -353,3 +354,41 @@ def paginate(self, **kwargs) -> QuerySet[T]: @abc.abstractmethod def get(self, request_options: Optional[RequestOptions] = None) -> tuple[list[T], PaginationItem]: raise NotImplementedError(f".get has not been implemented for {self.__class__.__qualname__}") + + def fields(self: Self, *fields: str) -> QuerySet: + """ + Add fields to the request options. If no fields are provided, the + default fields will be used. If fields are provided, the default fields + will be used in addition to the provided fields. + + Parameters + ---------- + fields : str + The fields to include in the request options. + + Returns + ------- + QuerySet + """ + queryset = QuerySet(self) + queryset.request_options.fields |= set(fields) | set(("_default_",)) + return queryset + + def only_fields(self: Self, *fields: str) -> QuerySet: + """ + Add fields to the request options. If no fields are provided, the + default fields will be used. If fields are provided, the default fields + will be replaced by the provided fields. + + Parameters + ---------- + fields : str + The fields to include in the request options. + + Returns + ------- + QuerySet + """ + queryset = QuerySet(self) + queryset.request_options.fields |= set(fields) + return queryset diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index eec4536f9..090d400b6 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -7,7 +7,7 @@ from .endpoint import Endpoint, api, parameter_added_in from .exceptions import MissingRequiredFieldError from tableauserverclient.server import RequestFactory -from tableauserverclient.models import PaginationItem, ScheduleItem, TaskItem +from tableauserverclient.models import PaginationItem, ScheduleItem, TaskItem, ExtractItem from tableauserverclient.helpers.logging import logger @@ -30,6 +30,23 @@ def siteurl(self) -> str: @api(version="2.3") def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[ScheduleItem], PaginationItem]: + """ + Returns a list of flows, extract, and subscription server schedules on + Tableau Server. For each schedule, the API returns name, frequency, + priority, and other information. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#query_schedules + + Parameters + ---------- + req_options : Optional[RequestOptions] + Filtering and paginating options for request. + + Returns + ------- + Tuple[List[ScheduleItem], PaginationItem] + A tuple of list of ScheduleItem and PaginationItem + """ logger.info("Querying all schedules") url = self.baseurl server_response = self.get_request(url, req_options) @@ -38,7 +55,22 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Sche return all_schedule_items, pagination_item @api(version="3.8") - def get_by_id(self, schedule_id): + def get_by_id(self, schedule_id: str) -> ScheduleItem: + """ + Returns detailed information about the specified server schedule. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#get-schedule + + Parameters + ---------- + schedule_id : str + The ID of the schedule to get information for. + + Returns + ------- + ScheduleItem + The schedule item that corresponds to the given ID. + """ if not schedule_id: error = "No Schedule ID provided" raise ValueError(error) @@ -49,6 +81,20 @@ def get_by_id(self, schedule_id): @api(version="2.3") def delete(self, schedule_id: str) -> None: + """ + Deletes the specified schedule from the server. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#delete_schedule + + Parameters + ---------- + schedule_id : str + The ID of the schedule to delete. + + Returns + ------- + None + """ if not schedule_id: error = "Schedule ID undefined" raise ValueError(error) @@ -58,6 +104,23 @@ def delete(self, schedule_id: str) -> None: @api(version="2.3") def update(self, schedule_item: ScheduleItem) -> ScheduleItem: + """ + Modifies settings for the specified server schedule, including the name, + priority, and frequency details on Tableau Server. For Tableau Cloud, + see the tasks and subscritpions API. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#update_schedule + + Parameters + ---------- + schedule_item : ScheduleItem + The schedule item to update. + + Returns + ------- + ScheduleItem + The updated schedule item. + """ if not schedule_item.id: error = "Schedule item missing ID." raise MissingRequiredFieldError(error) @@ -71,6 +134,20 @@ def update(self, schedule_item: ScheduleItem) -> ScheduleItem: @api(version="2.3") def create(self, schedule_item: ScheduleItem) -> ScheduleItem: + """ + Creates a new server schedule on Tableau Server. For Tableau Cloud, use + the tasks and subscriptions API. + + Parameters + ---------- + schedule_item : ScheduleItem + The schedule item to create. + + Returns + ------- + ScheduleItem + The newly created schedule. + """ if schedule_item.interval_item is None: error = "Interval item must be defined." raise MissingRequiredFieldError(error) @@ -92,6 +169,41 @@ def add_to_schedule( flow: Optional["FlowItem"] = None, task_type: Optional[str] = None, ) -> list[AddResponse]: + """ + Adds a workbook, datasource, or flow to a schedule on Tableau Server. + Only one of workbook, datasource, or flow can be passed in at a time. + + The task type is optional and will default to ExtractRefresh if a + workbook or datasource is passed in, and RunFlow if a flow is passed in. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#add_workbook_to_schedule + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#add_data_source_to_schedule + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#add_flow_task_to_schedule + + Parameters + ---------- + schedule_id : str + The ID of the schedule to add the item to. + + workbook : Optional[WorkbookItem] + The workbook to add to the schedule. + + datasource : Optional[DatasourceItem] + The datasource to add to the schedule. + + flow : Optional[FlowItem] + The flow to add to the schedule. + + task_type : Optional[str] + The type of task to add to the schedule. If not provided, it will + default to ExtractRefresh if a workbook or datasource is passed in, + and RunFlow if a flow is passed in. + + Returns + ------- + list[AddResponse] + A list of responses for each item added to the schedule. + """ # There doesn't seem to be a good reason to allow one item of each type? if workbook and datasource: warnings.warn("Passing in multiple items for add_to_schedule will be deprecated", PendingDeprecationWarning) @@ -149,3 +261,21 @@ def _add_to( ) else: return OK + + @api(version="2.3") + def get_extract_refresh_tasks( + self, schedule_id: str, req_options: Optional["RequestOptions"] = None + ) -> tuple[list["ExtractItem"], "PaginationItem"]: + """Get all extract refresh tasks for the specified schedule.""" + if not schedule_id: + error = "Schedule ID undefined" + raise ValueError(error) + + logger.info(f"Querying extract refresh tasks for schedule (ID: {schedule_id})") + url = f"{self.siteurl}/{schedule_id}/extracts" + server_response = self.get_request(url, req_options) + + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + extract_items = ExtractItem.from_response(server_response.content, self.parent_srv.namespace) + + return extract_items, pagination_item diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index 55d2a5ad0..e2316fbb8 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -4,7 +4,7 @@ from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError from tableauserverclient.server import RequestFactory -from tableauserverclient.models import SiteItem, PaginationItem +from tableauserverclient.models import SiteAuthConfiguration, SiteItem, PaginationItem from tableauserverclient.helpers.logging import logger @@ -418,3 +418,20 @@ def re_encrypt_extracts(self, site_id: str) -> None: empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) + + @api(version="3.24") + def list_auth_configurations(self) -> list[SiteAuthConfiguration]: + """ + Lists all authentication configurations on the current site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_site.htm#list_authentication_configurations_site + + Returns + ------- + list[SiteAuthConfiguration] + A list of authentication configurations on the current site. + """ + url = f"{self.baseurl}/{self.parent_srv.site_id}/site-auth-configurations" + server_response = self.get_request(url) + auth_configurations = SiteAuthConfiguration.from_response(server_response.content, self.parent_srv.namespace) + return auth_configurations diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index 120d3ba9c..ad80e7d0e 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -1,7 +1,8 @@ import logging -from typing import Union +from typing import Optional, Union, TYPE_CHECKING from collections.abc import Iterable +from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint from tableauserverclient.server.endpoint.endpoint import api, Endpoint from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError @@ -12,6 +13,10 @@ from tableauserverclient.server.pager import Pager from tableauserverclient.helpers.logging import logger +from tableauserverclient.server.request_options import RequestOptions + +if TYPE_CHECKING: + from tableauserverclient.models import DQWItem, PermissionsRule class Tables(Endpoint, TaggingMixin[TableItem]): @@ -22,11 +27,29 @@ def __init__(self, parent_srv): self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "table") @property - def baseurl(self): + def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tables" @api(version="3.5") - def get(self, req_options=None): + def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[TableItem], PaginationItem]: + """ + Get information about all tables on the site. Endpoint is paginated, and + will return a default of 100 items per page. Use the `req_options` + parameter to customize the request. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_tables + + Parameters + ---------- + req_options : RequestOptions, optional + Options to customize the request. If not provided, defaults to None. + + Returns + ------- + tuple[list[TableItem], PaginationItem] + A tuple containing a list of TableItem objects and a PaginationItem + object. + """ logger.info("Querying all tables on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -36,7 +59,27 @@ def get(self, req_options=None): # Get 1 table @api(version="3.5") - def get_by_id(self, table_id): + def get_by_id(self, table_id: str) -> TableItem: + """ + Get information about a single table on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_table + + Parameters + ---------- + table_id : str + The ID of the table to retrieve. + + Returns + ------- + TableItem + A TableItem object representing the table. + + Raises + ------ + ValueError + If the table ID is not provided. + """ if not table_id: error = "table ID undefined." raise ValueError(error) @@ -46,7 +89,24 @@ def get_by_id(self, table_id): return TableItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="3.5") - def delete(self, table_id): + def delete(self, table_id: str) -> None: + """ + Delete a single table from the server. + + Parameters + ---------- + table_id : str + The ID of the table to delete. + + Returns + ------- + None + + Raises + ------ + ValueError + If the table ID is not provided. + """ if not table_id: error = "Database ID undefined." raise ValueError(error) @@ -55,7 +115,27 @@ def delete(self, table_id): logger.info(f"Deleted single table (ID: {table_id})") @api(version="3.5") - def update(self, table_item): + def update(self, table_item: TableItem) -> TableItem: + """ + Update a table on the server. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#update_table + + Parameters + ---------- + table_item : TableItem + The TableItem object to update. + + Returns + ------- + TableItem + The updated TableItem object. + + Raises + ------ + MissingRequiredFieldError + If the table item is missing an ID. + """ if not table_item.id: error = "table item missing ID." raise MissingRequiredFieldError(error) @@ -69,21 +149,46 @@ def update(self, table_item): # Get all columns of the table @api(version="3.5") - def populate_columns(self, table_item, req_options=None): + def populate_columns(self, table_item: TableItem, req_options: Optional[RequestOptions] = None) -> None: + """ + Populate the columns of a table item. Sets a fetcher function to + retrieve the columns when needed. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_columns + + Parameters + ---------- + table_item : TableItem + The TableItem object to populate columns for. + + req_options : RequestOptions, optional + Options to customize the request. If not provided, defaults to None. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the table item is missing an ID. + """ if not table_item.id: error = "Table item missing ID. table must be retrieved from server first." raise MissingRequiredFieldError(error) def column_fetcher(): return Pager( - lambda options: self._get_columns_for_table(table_item, options), + lambda options: self._get_columns_for_table(table_item, options), # type: ignore req_options, ) table_item._set_columns(column_fetcher) logger.info(f"Populated columns for table (ID: {table_item.id}") - def _get_columns_for_table(self, table_item, req_options=None): + def _get_columns_for_table( + self, table_item: TableItem, req_options: Optional[RequestOptions] = None + ) -> tuple[list[ColumnItem], PaginationItem]: url = f"{self.baseurl}/{table_item.id}/columns" server_response = self.get_request(url, req_options) columns = ColumnItem.from_response(server_response.content, self.parent_srv.namespace) @@ -91,7 +196,25 @@ def _get_columns_for_table(self, table_item, req_options=None): return columns, pagination_item @api(version="3.5") - def update_column(self, table_item, column_item): + def update_column(self, table_item: TableItem, column_item: ColumnItem) -> ColumnItem: + """ + Update the description of a column in a table. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#update_column + + Parameters + ---------- + table_item : TableItem + The TableItem object representing the table. + + column_item : ColumnItem + The ColumnItem object representing the column to update. + + Returns + ------- + ColumnItem + The updated ColumnItem object. + """ url = f"{self.baseurl}/{table_item.id}/columns/{column_item.id}" update_req = RequestFactory.Column.update_req(column_item) server_response = self.put_request(url, update_req) @@ -101,31 +224,31 @@ def update_column(self, table_item, column_item): return column @api(version="3.5") - def populate_permissions(self, item): + def populate_permissions(self, item: TableItem) -> None: self._permissions.populate(item) @api(version="3.5") - def update_permissions(self, item, rules): + def update_permissions(self, item: TableItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: return self._permissions.update(item, rules) @api(version="3.5") - def delete_permission(self, item, rules): + def delete_permission(self, item: TableItem, rules: list[PermissionsRule]) -> None: return self._permissions.delete(item, rules) @api(version="3.5") - def populate_dqw(self, item): + def populate_dqw(self, item: TableItem) -> None: self._data_quality_warnings.populate(item) @api(version="3.5") - def update_dqw(self, item, warning): + def update_dqw(self, item: TableItem, warning: "DQWItem") -> list["DQWItem"]: return self._data_quality_warnings.update(item, warning) @api(version="3.5") - def add_dqw(self, item, warning): + def add_dqw(self, item: TableItem, warning: "DQWItem") -> list["DQWItem"]: return self._data_quality_warnings.add(item, warning) @api(version="3.5") - def delete_dqw(self, item): + def delete_dqw(self, item: TableItem) -> None: self._data_quality_warnings.clear(item) @api(version="3.9") diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index d81907ae9..17af21a03 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -87,7 +87,7 @@ def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[UserIt if req_options is None: req_options = RequestOptions() - req_options._all_fields = True + req_options.all_fields = True url = self.baseurl server_response = self.get_request(url, req_options) @@ -381,10 +381,15 @@ def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[Us # Get workbooks for user @api(version="2.0") - def populate_workbooks(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: + def populate_workbooks( + self, user_item: UserItem, req_options: Optional[RequestOptions] = None, owned_only: bool = False + ) -> None: """ Returns information about the workbooks that the specified user owns - and has Read (view) permissions for. + or has Read (view) permissions for. If owned_only is set to True, + only the workbooks that the user owns are returned. If owned_only is + set to False, all workbooks that the user has Read (view) permissions + for are returned. This method retrieves the workbook information for the specified user. The REST API is designed to return only the information you ask for @@ -402,6 +407,10 @@ def populate_workbooks(self, user_item: UserItem, req_options: Optional[RequestO req_options : Optional[RequestOptions] Optional request options to filter and sort the results. + owned_only : bool, default=False + If True, only the workbooks that the user owns are returned. + If False, all workbooks that the user has Read (view) permissions + Returns ------- None @@ -423,14 +432,22 @@ def populate_workbooks(self, user_item: UserItem, req_options: Optional[RequestO raise MissingRequiredFieldError(error) def wb_pager(): - return Pager(lambda options: self._get_wbs_for_user(user_item, options), req_options) + def func(req_options): + return self._get_wbs_for_user(user_item, req_options, owned_only=owned_only) + + return Pager(func, req_options) user_item._set_workbooks(wb_pager) def _get_wbs_for_user( - self, user_item: UserItem, req_options: Optional[RequestOptions] = None + self, + user_item: UserItem, + req_options: Optional[RequestOptions] = None, + owned_only: bool = False, ) -> tuple[list[WorkbookItem], PaginationItem]: url = f"{self.baseurl}/{user_item.id}/workbooks" + if owned_only: + url += "?ownedBy=true" server_response = self.get_request(url, req_options) logger.info(f"Populated workbooks for user (ID: {user_item.id})") workbook_item = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index 801ad4a13..5137cee52 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -208,6 +208,42 @@ def paginate(self: Self, **kwargs) -> Self: self.request_options.pagesize = kwargs["page_size"] return self + def fields(self: Self, *fields: str) -> Self: + """ + Add fields to the request options. If no fields are provided, the + default fields will be used. If fields are provided, the default fields + will be used in addition to the provided fields. + + Parameters + ---------- + fields : str + The fields to include in the request options. + + Returns + ------- + QuerySet + """ + self.request_options.fields |= set(fields) | set(("_default_")) + return self + + def only_fields(self: Self, *fields: str) -> Self: + """ + Add fields to the request options. If no fields are provided, the + default fields will be used. If fields are provided, the default fields + will be replaced by the provided fields. + + Parameters + ---------- + fields : str + The fields to include in the request options. + + Returns + ------- + QuerySet + """ + self.request_options.fields |= set(fields) + return self + @staticmethod def _parse_shorthand_filter(key: str) -> tuple[str, str]: tokens = key.split("__", 1) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 575423612..c898004f7 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -913,6 +913,8 @@ def update_req(self, user_item: UserItem, password: Optional[str]) -> bytes: user_element.attrib["authSetting"] = user_item.auth_setting if password: user_element.attrib["password"] = password + if user_item.idp_configuration_id is not None: + user_element.attrib["idpConfigurationId"] = user_item.idp_configuration_id return ET.tostring(xml_request) def add_req(self, user_item: UserItem) -> bytes: @@ -929,6 +931,9 @@ def add_req(self, user_item: UserItem) -> bytes: if user_item.auth_setting: user_element.attrib["authSetting"] = user_item.auth_setting + + if user_item.idp_configuration_id is not None: + user_element.attrib["idpConfigurationId"] = user_item.idp_configuration_id return ET.tostring(xml_request) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 504f7f3ca..4a104255f 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,5 +1,6 @@ import sys from typing import Optional +import warnings from typing_extensions import Self @@ -62,8 +63,21 @@ def __init__(self, pagenumber=1, pagesize=None): self.pagesize = pagesize or config.PAGE_SIZE self.sort = set() self.filter = set() + self.fields = set() # This is private until we expand all of our parsers to handle the extra fields - self._all_fields = False + self.all_fields = False + + @property + def _all_fields(self) -> bool: + return self.all_fields + + @_all_fields.setter + def _all_fields(self, value): + warnings.warn( + "Directly setting _all_fields is deprecated, please use the all_fields property instead.", + DeprecationWarning, + ) + self.all_fields = value def get_query_params(self) -> dict: params = {} @@ -75,12 +89,14 @@ def get_query_params(self) -> dict: filter_options = (str(filter_item) for filter_item in self.filter) ordered_filter_options = sorted(filter_options) params["filter"] = ",".join(ordered_filter_options) - if self._all_fields: + if self.all_fields: params["fields"] = "_all_" if self.pagenumber: params["pageNumber"] = self.pagenumber if self.pagesize: params["pageSize"] = self.pagesize + if self.fields: + params["fields"] = ",".join(self.fields) return params def page_size(self, page_size): @@ -181,6 +197,116 @@ class Direction: Desc = "desc" Asc = "asc" + class SelectFields: + class Common: + All = "_all_" + Default = "_default_" + + class ContentsCounts: + ProjectCount = "contentsCounts.projectCount" + ViewCount = "contentsCounts.viewCount" + DatasourceCount = "contentsCounts.datasourceCount" + WorkbookCount = "contentsCounts.workbookCount" + + class Datasource: + ContentUrl = "datasource.contentUrl" + ID = "datasource.id" + Name = "datasource.name" + Type = "datasource.type" + Description = "datasource.description" + CreatedAt = "datasource.createdAt" + UpdatedAt = "datasource.updatedAt" + EncryptExtracts = "datasource.encryptExtracts" + IsCertified = "datasource.isCertified" + UseRemoteQueryAgent = "datasource.useRemoteQueryAgent" + WebPageURL = "datasource.webpageUrl" + Size = "datasource.size" + Tag = "datasource.tag" + FavoritesTotal = "datasource.favoritesTotal" + DatabaseName = "datasource.databaseName" + ConnectedWorkbooksCount = "datasource.connectedWorkbooksCount" + HasAlert = "datasource.hasAlert" + HasExtracts = "datasource.hasExtracts" + IsPublished = "datasource.isPublished" + ServerName = "datasource.serverName" + + class Favorite: + Label = "favorite.label" + ParentProjectName = "favorite.parentProjectName" + TargetOwnerName = "favorite.targetOwnerName" + + class Group: + ID = "group.id" + Name = "group.name" + DomainName = "group.domainName" + UserCount = "group.userCount" + MinimumSiteRole = "group.minimumSiteRole" + + class Job: + ID = "job.id" + Status = "job.status" + CreatedAt = "job.createdAt" + StartedAt = "job.startedAt" + EndedAt = "job.endedAt" + Priority = "job.priority" + JobType = "job.jobType" + Title = "job.title" + Subtitle = "job.subtitle" + + class Owner: + ID = "owner.id" + Name = "owner.name" + FullName = "owner.fullName" + SiteRole = "owner.siteRole" + LastLogin = "owner.lastLogin" + Email = "owner.email" + + class Project: + ID = "project.id" + Name = "project.name" + Description = "project.description" + CreatedAt = "project.createdAt" + UpdatedAt = "project.updatedAt" + ContentPermissions = "project.contentPermissions" + ParentProjectID = "project.parentProjectId" + TopLevelProject = "project.topLevelProject" + Writeable = "project.writeable" + + class User: + ExternalAuthUserId = "user.externalAuthUserId" + ID = "user.id" + Name = "user.name" + SiteRole = "user.siteRole" + LastLogin = "user.lastLogin" + FullName = "user.fullName" + Email = "user.email" + AuthSetting = "user.authSetting" + + class View: + ID = "view.id" + Name = "view.name" + ContentUrl = "view.contentUrl" + CreatedAt = "view.createdAt" + UpdatedAt = "view.updatedAt" + Tags = "view.tags" + SheetType = "view.sheetType" + Usage = "view.usage" + + class Workbook: + ID = "workbook.id" + Description = "workbook.description" + Name = "workbook.name" + ContentUrl = "workbook.contentUrl" + ShowTabs = "workbook.showTabs" + Size = "workbook.size" + CreatedAt = "workbook.createdAt" + UpdatedAt = "workbook.updatedAt" + SheetCount = "workbook.sheetCount" + HasExtracts = "workbook.hasExtracts" + Tags = "workbook.tags" + WebpageUrl = "workbook.webpageUrl" + DefaultViewId = "workbook.defaultViewId" + """ These options can be used by methods that are fetching data exported from a specific content item diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 30c635e31..d5d163db3 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -2,6 +2,7 @@ import requests import urllib3 +import ssl from defusedxml.ElementTree import fromstring, ParseError from packaging.version import Version @@ -91,6 +92,13 @@ class Server: and a later version of the REST API. For more information, see REST API Versions. + http_options : dict, optional + Additional options to pass to the requests library when making HTTP requests. + + session_factory : callable, optional + A factory function that returns a requests.Session object. If not provided, + requests.session is used. + Examples -------- >>> import tableauserverclient as TSC @@ -107,6 +115,16 @@ class Server: >>> # for example, 2.8 >>> # server.version = '2.8' + >>> # if connecting to an older Tableau Server with weak DH keys (Python 3.12+ only) + >>> server.configure_ssl(allow_weak_dh=True) # Note: reduces security + + Notes + ----- + When using Python 3.12 or later with older versions of Tableau Server, you may encounter + SSL errors related to weak Diffie-Hellman keys. This is because newer Python versions + enforce stronger security requirements. You can temporarily work around this using + configure_ssl(allow_weak_dh=True), but this reduces security and should only be used + as a temporary measure until the server can be upgraded. """ class PublishMode: @@ -125,6 +143,7 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self._auth_token = None self._site_id = None self._user_id = None + self._ssl_context = None # TODO: this needs to change to default to https, but without breaking existing code if not server_address.startswith("http://") and not server_address.startswith("https://"): @@ -313,3 +332,26 @@ def session(self): def is_signed_in(self): return self._auth_token is not None + + def configure_ssl(self, *, allow_weak_dh=False): + """Configure SSL/TLS settings for the server connection. + + Parameters + ---------- + allow_weak_dh : bool, optional + If True, allows connections to servers with DH keys that are considered too small by modern Python versions. + WARNING: This reduces security and should only be used as a temporary workaround. + """ + if allow_weak_dh: + logger.warning( + "WARNING: Allowing weak Diffie-Hellman keys. This reduces security and should only be used temporarily." + ) + self._ssl_context = ssl.create_default_context() + # Allow weak DH keys by setting minimum key size to 512 bits (default is 1024 in Python 3.12+) + self._ssl_context.set_dh_parameters(min_key_bits=512) + self.add_http_options({"verify": self._ssl_context}) + else: + self._ssl_context = None + # Remove any custom SSL context if we're reverting to default settings + if "verify" in self._http_options: + del self._http_options["verify"] diff --git a/test/assets/datasource_get_all_fields.xml b/test/assets/datasource_get_all_fields.xml new file mode 100644 index 000000000..46c4396d3 --- /dev/null +++ b/test/assets/datasource_get_all_fields.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/test/assets/group_get_all_fields.xml b/test/assets/group_get_all_fields.xml new file mode 100644 index 000000000..0118250e1 --- /dev/null +++ b/test/assets/group_get_all_fields.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/project_get_all_fields.xml b/test/assets/project_get_all_fields.xml new file mode 100644 index 000000000..d71ebd922 --- /dev/null +++ b/test/assets/project_get_all_fields.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/test/assets/schedule_get_extract_refresh_tasks.xml b/test/assets/schedule_get_extract_refresh_tasks.xml new file mode 100644 index 000000000..48906dde6 --- /dev/null +++ b/test/assets/schedule_get_extract_refresh_tasks.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/site_auth_configurations.xml b/test/assets/site_auth_configurations.xml new file mode 100644 index 000000000..c81d179ac --- /dev/null +++ b/test/assets/site_auth_configurations.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/test/assets/user_get_all_fields.xml b/test/assets/user_get_all_fields.xml new file mode 100644 index 000000000..7e9a62568 --- /dev/null +++ b/test/assets/user_get_all_fields.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/test/assets/view_get_all_fields.xml b/test/assets/view_get_all_fields.xml new file mode 100644 index 000000000..236ebd726 --- /dev/null +++ b/test/assets/view_get_all_fields.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/assets/workbook_get_all_fields.xml b/test/assets/workbook_get_all_fields.xml new file mode 100644 index 000000000..007b79338 --- /dev/null +++ b/test/assets/workbook_get_all_fields.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/request_factory/test_task_requests.py b/test/request_factory/test_task_requests.py index 0258b8a93..6287fa6ea 100644 --- a/test/request_factory/test_task_requests.py +++ b/test/request_factory/test_task_requests.py @@ -5,7 +5,6 @@ class TestTaskRequest(unittest.TestCase): - def setUp(self): self.task_request = TaskRequest() self.xml_request = ET.Element("tsRequest") diff --git a/test/test_datasource.py b/test/test_datasource.py index b7e7e2721..a604ba8b0 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -10,7 +10,7 @@ import tableauserverclient as TSC from tableauserverclient import ConnectionItem -from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.datetime_helpers import format_datetime, parse_datetime from tableauserverclient.server.endpoint.exceptions import InternalServerError from tableauserverclient.server.endpoint.fileuploads_endpoint import Fileuploads from tableauserverclient.server.request_factory import RequestFactory @@ -20,6 +20,7 @@ GET_XML = "datasource_get.xml" GET_EMPTY_XML = "datasource_get_empty.xml" GET_BY_ID_XML = "datasource_get_by_id.xml" +GET_XML_ALL_FIELDS = "datasource_get_all_fields.xml" POPULATE_CONNECTIONS_XML = "datasource_populate_connections.xml" POPULATE_PERMISSIONS_XML = "datasource_populate_permissions.xml" PUBLISH_XML = "datasource_publish.xml" @@ -733,3 +734,39 @@ def test_bad_download_response(self) -> None: ) file_path = self.server.datasources.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", td) self.assertTrue(os.path.exists(file_path)) + + def test_get_datasource_all_fields(self) -> None: + ro = TSC.RequestOptions() + ro.all_fields = True + with requests_mock.mock() as m: + m.get(f"{self.baseurl}?fields=_all_", text=read_xml_asset(GET_XML_ALL_FIELDS)) + datasources, _ = self.server.datasources.get(req_options=ro) + + assert datasources[0].connected_workbooks_count == 0 + assert datasources[0].content_url == "SuperstoreDatasource" + assert datasources[0].created_at == parse_datetime("2024-02-14T04:42:13Z") + assert not datasources[0].encrypt_extracts + assert datasources[0].favorites_total == 0 + assert not datasources[0].has_alert + assert not datasources[0].has_extracts + assert datasources[0].id == "a71cdd15-3a23-4ec1-b3ce-9956f5e00bb7" + assert not datasources[0].certified + assert datasources[0].is_published + assert datasources[0].name == "Superstore Datasource" + assert datasources[0].size == 1 + assert datasources[0].datasource_type == "excel-direct" + assert datasources[0].updated_at == parse_datetime("2024-02-14T04:42:14Z") + assert not datasources[0].use_remote_query_agent + assert datasources[0].server_name == "localhost" + assert datasources[0].webpage_url == "https://10ax.online.tableau.com/#/site/example/datasources/3566752" + assert isinstance(datasources[0].project, TSC.ProjectItem) + assert datasources[0].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert datasources[0].project.name == "Samples" + assert datasources[0].project.description == "This project includes automatically uploaded samples." + assert datasources[0].owner.email == "bob@example.com" + assert isinstance(datasources[0].owner, TSC.UserItem) + assert datasources[0].owner.fullname == "Bob Smith" + assert datasources[0].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert datasources[0].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert datasources[0].owner.name == "bob@example.com" + assert datasources[0].owner.site_role == "SiteAdministratorCreator" diff --git a/test/test_group.py b/test/test_group.py index 41b5992be..b3de07963 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -10,6 +10,7 @@ # TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") GET_XML = os.path.join(TEST_ASSET_DIR, "group_get.xml") +GET_XML_ALL_FIELDS = TEST_ASSET_DIR / "group_get_all_fields.xml" POPULATE_USERS = os.path.join(TEST_ASSET_DIR, "group_populate_users.xml") POPULATE_USERS_EMPTY = os.path.join(TEST_ASSET_DIR, "group_populate_users_empty.xml") ADD_USER = os.path.join(TEST_ASSET_DIR, "group_add_user.xml") @@ -310,3 +311,25 @@ def test_update_ad_async(self) -> None: self.assertEqual(job.id, "c2566efc-0767-4f15-89cb-56acb4349c1b") self.assertEqual(job.mode, "Asynchronous") self.assertEqual(job.type, "GroupSync") + + def test_get_all_fields(self) -> None: + ro = TSC.RequestOptions() + ro.all_fields = True + self.server.version = "3.21" + self.baseurl = self.server.groups.baseurl + with requests_mock.mock() as m: + m.get(f"{self.baseurl}?fields=_all_", text=GET_XML_ALL_FIELDS.read_text()) + groups, pages = self.server.groups.get(req_options=ro) + + assert pages.total_available == 3 + assert len(groups) == 3 + assert groups[0].id == "28c5b855-16df-482f-ad0b-428c1df58859" + assert groups[0].name == "All Users" + assert groups[0].user_count == 2 + assert groups[0].domain_name == "local" + assert groups[1].id == "ace1ee2d-e7dd-4d7a-9504-a1ccaa5212ea" + assert groups[1].name == "group1" + assert groups[1].user_count == 1 + assert groups[2].id == "baf0ed9d-c25d-4114-97ed-5232b8a732fd" + assert groups[2].name == "test" + assert groups[2].user_count == 0 diff --git a/test/test_project.py b/test/test_project.py index 56787efac..c51f2e1e6 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -10,6 +10,7 @@ TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") GET_XML = asset("project_get.xml") +GET_XML_ALL_FIELDS = asset("project_get_all_fields.xml") UPDATE_XML = asset("project_update.xml") SET_CONTENT_PERMISSIONS_XML = asset("project_content_permission.xml") CREATE_XML = asset("project_create.xml") @@ -410,3 +411,28 @@ def test_delete_virtualconnection_default_permimssions(self): m.delete(f"{base_url}/{endpoint}/Connect/Allow", status_code=204) self.server.projects.delete_virtualconnection_default_permissions(project, rule) + + def test_get_all_fields(self) -> None: + self.server.version = "3.23" + base_url = self.server.projects.baseurl + with open(GET_XML_ALL_FIELDS, "rb") as f: + response_xml = f.read().decode("utf-8") + + ro = TSC.RequestOptions() + ro.all_fields = True + + with requests_mock.mock() as m: + m.get(f"{base_url}?fields=_all_", text=response_xml) + all_projects, pagination_item = self.server.projects.get(req_options=ro) + + assert pagination_item.total_available == 3 + assert len(all_projects) == 1 + project: TSC.ProjectItem = all_projects[0] + assert isinstance(project, TSC.ProjectItem) + assert project.id == "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" + assert project.name == "Samples" + assert project.description == "This project includes automatically uploaded samples." + assert project.top_level_project is True + assert project.content_permissions == "ManagedByOwner" + assert project.parent_id is None + assert project.writeable is True diff --git a/test/test_request_option.py b/test/test_request_option.py index 7405189a3..57dfdc2a0 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -251,7 +251,7 @@ def test_all_fields(self) -> None: m.get(requests_mock.ANY) url = self.baseurl + "/views/456/data" opts = TSC.RequestOptions() - opts._all_fields = True + opts.all_fields = True resp = self.server.users.get_request(url, request_object=opts) self.assertTrue(re.search("fields=_all_", resp.request.query)) @@ -368,3 +368,13 @@ def test_language_export(self) -> None: resp = self.server.users.get_request(url, request_object=opts) self.assertTrue(re.search("language=en-us", resp.request.query)) + + def test_queryset_fields(self) -> None: + loop = self.server.users.fields("id") + assert "id" in loop.request_options.fields + assert "_default_" in loop.request_options.fields + + def test_queryset_only_fields(self) -> None: + loop = self.server.users.only_fields("id") + assert "id" in loop.request_options.fields + assert "_default_" not in loop.request_options.fields diff --git a/test/test_schedule.py b/test/test_schedule.py index b072522a4..4fcc85e18 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -25,6 +25,7 @@ ADD_WORKBOOK_TO_SCHEDULE_WITH_WARNINGS = os.path.join(TEST_ASSET_DIR, "schedule_add_workbook_with_warnings.xml") ADD_DATASOURCE_TO_SCHEDULE = os.path.join(TEST_ASSET_DIR, "schedule_add_datasource.xml") ADD_FLOW_TO_SCHEDULE = os.path.join(TEST_ASSET_DIR, "schedule_add_flow.xml") +GET_EXTRACT_TASKS_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_extract_refresh_tasks.xml") WORKBOOK_GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_by_id.xml") DATASOURCE_GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "datasource_get_by_id.xml") @@ -405,3 +406,20 @@ def test_add_flow(self) -> None: flow = self.server.flows.get_by_id("bar") result = self.server.schedules.add_to_schedule("foo", flow=flow) self.assertEqual(0, len(result), "Added properly") + + def test_get_extract_refresh_tasks(self) -> None: + self.server.version = "2.3" + + with open(GET_EXTRACT_TASKS_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" + baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules/{schedule_id}/extracts" + m.get(baseurl, text=response_xml) + + extracts = self.server.schedules.get_extract_refresh_tasks(schedule_id) + + self.assertIsNotNone(extracts) + self.assertIsInstance(extracts[0], list) + self.assertEqual(2, len(extracts[0])) + self.assertEqual("task1", extracts[0][0].id) diff --git a/test/test_site.py b/test/test_site.py index 96b75f9ff..243810254 100644 --- a/test/test_site.py +++ b/test/test_site.py @@ -13,6 +13,7 @@ GET_BY_NAME_XML = os.path.join(TEST_ASSET_DIR, "site_get_by_name.xml") UPDATE_XML = os.path.join(TEST_ASSET_DIR, "site_update.xml") CREATE_XML = os.path.join(TEST_ASSET_DIR, "site_create.xml") +SITE_AUTH_CONFIG_XML = os.path.join(TEST_ASSET_DIR, "site_auth_configurations.xml") class SiteTests(unittest.TestCase): @@ -260,3 +261,28 @@ def test_decrypt(self) -> None: with requests_mock.mock() as m: m.post(self.baseurl + "/0626857c-1def-4503-a7d8-7907c3ff9d9f/decrypt-extracts", status_code=200) self.server.sites.decrypt_extracts("0626857c-1def-4503-a7d8-7907c3ff9d9f") + + def test_list_auth_configurations(self) -> None: + self.server.version = "3.24" + self.baseurl = self.server.sites.baseurl + with open(SITE_AUTH_CONFIG_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + + assert self.baseurl == self.server.sites.baseurl + + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{self.server.site_id}/site-auth-configurations", status_code=200, text=response_xml) + configs = self.server.sites.list_auth_configurations() + + assert len(configs) == 2, "Expected 2 auth configurations" + + assert configs[0].auth_setting == "OIDC" + assert configs[0].enabled + assert configs[0].idp_configuration_id == "00000000-0000-0000-0000-000000000000" + assert configs[0].idp_configuration_name == "Initial Salesforce" + assert configs[0].known_provider_alias == "Salesforce" + assert configs[1].auth_setting == "SAML" + assert configs[1].enabled + assert configs[1].idp_configuration_id == "11111111-1111-1111-1111-111111111111" + assert configs[1].idp_configuration_name == "Initial SAML" + assert configs[1].known_provider_alias is None diff --git a/test/test_ssl_config.py b/test/test_ssl_config.py new file mode 100644 index 000000000..036a326ca --- /dev/null +++ b/test/test_ssl_config.py @@ -0,0 +1,77 @@ +import unittest +import ssl +from unittest.mock import patch, MagicMock +from tableauserverclient import Server +from tableauserverclient.server.endpoint import Endpoint +import logging + + +class TestSSLConfig(unittest.TestCase): + @patch("requests.session") + @patch("tableauserverclient.server.endpoint.Endpoint.set_parameters") + def setUp(self, mock_set_parameters, mock_session): + """Set up test fixtures with mocked session and request validation""" + # Mock the session + self.mock_session = MagicMock() + mock_session.return_value = self.mock_session + + # Mock request preparation + self.mock_request = MagicMock() + self.mock_session.prepare_request.return_value = self.mock_request + + # Create server instance with mocked components + self.server = Server("http://test") + + def test_default_ssl_config(self): + """Test that by default, no custom SSL context is used""" + self.assertIsNone(self.server._ssl_context) + self.assertNotIn("verify", self.server.http_options) + + @patch("ssl.create_default_context") + def test_weak_dh_config(self, mock_create_context): + """Test that weak DH keys can be allowed when configured""" + # Setup mock SSL context + mock_context = MagicMock() + mock_create_context.return_value = mock_context + + # Configure SSL with weak DH + self.server.configure_ssl(allow_weak_dh=True) + + # Verify SSL context was created and configured correctly + mock_create_context.assert_called_once() + mock_context.set_dh_parameters.assert_called_once_with(min_key_bits=512) + + # Verify context was added to http options + self.assertEqual(self.server.http_options["verify"], mock_context) + + @patch("ssl.create_default_context") + def test_disable_weak_dh_config(self, mock_create_context): + """Test that SSL config can be reset to defaults""" + # Setup mock SSL context + mock_context = MagicMock() + mock_create_context.return_value = mock_context + + # First enable weak DH + self.server.configure_ssl(allow_weak_dh=True) + self.assertIsNotNone(self.server._ssl_context) + self.assertIn("verify", self.server.http_options) + + # Then disable it + self.server.configure_ssl(allow_weak_dh=False) + self.assertIsNone(self.server._ssl_context) + self.assertNotIn("verify", self.server.http_options) + + @patch("ssl.create_default_context") + def test_warning_on_weak_dh(self, mock_create_context): + """Test that a warning is logged when enabling weak DH keys""" + logging.getLogger().setLevel(logging.WARNING) + with self.assertLogs(level="WARNING") as log: + self.server.configure_ssl(allow_weak_dh=True) + self.assertTrue( + any("WARNING: Allowing weak Diffie-Hellman keys" in record for record in log.output), + "Expected warning about weak DH keys was not logged", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_user.py b/test/test_user.py index a46624845..fa2ac3a12 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -1,14 +1,16 @@ import os import unittest +from defusedxml import ElementTree as ET import requests_mock import tableauserverclient as TSC -from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.datetime_helpers import format_datetime, parse_datetime TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") GET_XML = os.path.join(TEST_ASSET_DIR, "user_get.xml") +GET_XML_ALL_FIELDS = os.path.join(TEST_ASSET_DIR, "user_get_all_fields.xml") GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "user_get_empty.xml") GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "user_get_by_id.xml") UPDATE_XML = os.path.join(TEST_ASSET_DIR, "user_update.xml") @@ -162,6 +164,22 @@ def test_populate_workbooks(self) -> None: self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", workbook_list[0].owner_id) self.assertEqual({"Safari", "Sample"}, workbook_list[0].tags) + def test_populate_owned_workbooks(self) -> None: + with open(POPULATE_WORKBOOKS_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + # Query parameter ownedBy is case sensitive. + with requests_mock.mock(case_sensitive=True) as m: + m.get(self.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794/workbooks?ownedBy=true", text=response_xml) + single_user = TSC.UserItem("test", "Interactor") + single_user._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + self.server.users.populate_workbooks(single_user, owned_only=True) + list(single_user.workbooks) + + request_history = m.request_history[0] + + assert "ownedBy" in request_history.qs, "ownedBy not in request history" + assert "true" in request_history.qs["ownedBy"], "ownedBy not set to true in request history" + def test_populate_workbooks_missing_id(self) -> None: single_user = TSC.UserItem("test", "Interactor") self.assertRaises(TSC.MissingRequiredFieldError, self.server.users.populate_workbooks, single_user) @@ -233,3 +251,72 @@ def test_get_users_from_file(self): users, failures = self.server.users.create_from_file(USERS) assert users[0].name == "Cassie", users assert failures == [] + + def test_get_users_all_fields(self) -> None: + self.server.version = "3.7" + baseurl = self.server.users.baseurl + with open(GET_XML_ALL_FIELDS) as f: + response_xml = f.read() + + with requests_mock.mock() as m: + m.get(f"{baseurl}?fields=_all_", text=response_xml) + all_users, _ = self.server.users.get() + + assert all_users[0].auth_setting == "TableauIDWithMFA" + assert all_users[0].email == "bob@example.com" + assert all_users[0].external_auth_user_id == "38c870c3ac5e84ec66e6ced9fb23681835b07e56d5660371ac1f705cc65bd610" + assert all_users[0].fullname == "Bob Smith" + assert all_users[0].id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert all_users[0].last_login == parse_datetime("2025-02-04T06:39:20Z") + assert all_users[0].name == "bob@example.com" + assert all_users[0].site_role == "SiteAdministratorCreator" + assert all_users[0].locale is None + assert all_users[0].language == "en" + assert all_users[0].idp_configuration_id == "22222222-2222-2222-2222-222222222222" + assert all_users[0].domain_name == "TABID_WITH_MFA" + assert all_users[1].auth_setting == "TableauIDWithMFA" + assert all_users[1].email == "alice@example.com" + assert all_users[1].external_auth_user_id == "96f66b893b22669cdfa632275d354cd1d92cea0266f3be7702151b9b8c52be29" + assert all_users[1].fullname == "Alice Jones" + assert all_users[1].id == "f6d72445-285b-48e5-8380-f90b519ce682" + assert all_users[1].name == "alice@example.com" + assert all_users[1].site_role == "ExplorerCanPublish" + assert all_users[1].locale is None + assert all_users[1].language == "en" + assert all_users[1].idp_configuration_id == "22222222-2222-2222-2222-222222222222" + assert all_users[1].domain_name == "TABID_WITH_MFA" + + def test_add_user_idp_configuration(self) -> None: + with open(ADD_XML) as f: + response_xml = f.read() + user = TSC.UserItem(name="Cassie", site_role="Viewer") + user.idp_configuration_id = "012345" + + with requests_mock.mock() as m: + m.post(self.server.users.baseurl, text=response_xml) + user = self.server.users.add(user) + + history = m.request_history[0] + + tree = ET.fromstring(history.text) + user_elem = tree.find(".//user") + assert user_elem is not None + assert user_elem.attrib["idpConfigurationId"] == "012345" + + def test_update_user_idp_configuration(self) -> None: + with open(ADD_XML) as f: + response_xml = f.read() + user = TSC.UserItem(name="Cassie", site_role="Viewer") + user._id = "0123456789" + user.idp_configuration_id = "012345" + + with requests_mock.mock() as m: + m.put(f"{self.server.users.baseurl}/{user.id}", text=response_xml) + user = self.server.users.update(user) + + history = m.request_history[0] + + tree = ET.fromstring(history.text) + user_elem = tree.find(".//user") + assert user_elem is not None + assert user_elem.attrib["idpConfigurationId"] == "012345" diff --git a/test/test_view.py b/test/test_view.py index 3fdaf60e6..ee6d518de 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -5,13 +5,14 @@ import tableauserverclient as TSC from tableauserverclient import UserItem, GroupItem, PermissionsRule -from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.datetime_helpers import format_datetime, parse_datetime from tableauserverclient.server.endpoint.exceptions import UnsupportedAttributeError TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") ADD_TAGS_XML = os.path.join(TEST_ASSET_DIR, "view_add_tags.xml") GET_XML = os.path.join(TEST_ASSET_DIR, "view_get.xml") +GET_XML_ALL_FIELDS = os.path.join(TEST_ASSET_DIR, "view_get_all_fields.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") @@ -402,3 +403,116 @@ def test_pdf_errors(self) -> None: req_option = TSC.PDFRequestOptions(viz_width=1920) with self.assertRaises(ValueError): req_option.get_query_params() + + def test_view_get_all_fields(self) -> None: + self.server.version = "3.21" + self.baseurl = self.server.views.baseurl + with open(GET_XML_ALL_FIELDS) as f: + response_xml = f.read() + + ro = TSC.RequestOptions() + ro.all_fields = True + + with requests_mock.mock() as m: + m.get(f"{self.baseurl}?fields=_all_", text=response_xml) + views, _ = self.server.views.get(req_options=ro) + + assert views[0].id == "2bdcd787-dcc6-4a5d-bc61-2846f1ef4534" + assert views[0].name == "Overview" + assert views[0].content_url == "Superstore/sheets/Overview" + assert views[0].created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[0].updated_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[0].sheet_type == "dashboard" + assert views[0].favorites_total == 0 + assert views[0].view_url_name == "Overview" + assert isinstance(views[0].workbook, TSC.WorkbookItem) + assert views[0].workbook.id == "9df3e2d1-070e-497a-9578-8cc557ced9df" + assert views[0].workbook.name == "Superstore" + assert views[0].workbook.content_url == "Superstore" + assert views[0].workbook.show_tabs + assert views[0].workbook.size == 2 + assert views[0].workbook.created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[0].workbook.updated_at == parse_datetime("2024-02-14T04:42:10Z") + assert views[0].workbook.sheet_count == 9 + assert not views[0].workbook.has_extracts + assert isinstance(views[0].owner, TSC.UserItem) + assert views[0].owner.email == "bob@example.com" + assert views[0].owner.fullname == "Bob" + assert views[0].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert views[0].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert views[0].owner.name == "bob@example.com" + assert views[0].owner.site_role == "SiteAdministratorCreator" + assert isinstance(views[0].project, TSC.ProjectItem) + assert views[0].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[0].project.name == "Samples" + assert views[0].project.description == "This project includes automatically uploaded samples." + assert views[0].total_views == 0 + assert isinstance(views[0].location, TSC.LocationItem) + assert views[0].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[0].location.type == "Project" + assert views[1].id == "2a3fd19d-9129-413d-9ff7-9dfc36bf7f7e" + assert views[1].name == "Product" + assert views[1].content_url == "Superstore/sheets/Product" + assert views[1].created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[1].updated_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[1].sheet_type == "dashboard" + assert views[1].favorites_total == 0 + assert views[1].view_url_name == "Product" + assert isinstance(views[1].workbook, TSC.WorkbookItem) + assert views[1].workbook.id == "9df3e2d1-070e-497a-9578-8cc557ced9df" + assert views[1].workbook.name == "Superstore" + assert views[1].workbook.content_url == "Superstore" + assert views[1].workbook.show_tabs + assert views[1].workbook.size == 2 + assert views[1].workbook.created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[1].workbook.updated_at == parse_datetime("2024-02-14T04:42:10Z") + assert views[1].workbook.sheet_count == 9 + assert not views[1].workbook.has_extracts + assert isinstance(views[1].owner, TSC.UserItem) + assert views[1].owner.email == "bob@example.com" + assert views[1].owner.fullname == "Bob" + assert views[1].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert views[1].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert views[1].owner.name == "bob@example.com" + assert views[1].owner.site_role == "SiteAdministratorCreator" + assert isinstance(views[1].project, TSC.ProjectItem) + assert views[1].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[1].project.name == "Samples" + assert views[1].project.description == "This project includes automatically uploaded samples." + assert views[1].total_views == 0 + assert isinstance(views[1].location, TSC.LocationItem) + assert views[1].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[1].location.type == "Project" + assert views[2].id == "459eda9a-85e4-46bf-a2f2-62936bd2e99a" + assert views[2].name == "Customers" + assert views[2].content_url == "Superstore/sheets/Customers" + assert views[2].created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[2].updated_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[2].sheet_type == "dashboard" + assert views[2].favorites_total == 0 + assert views[2].view_url_name == "Customers" + assert isinstance(views[2].workbook, TSC.WorkbookItem) + assert views[2].workbook.id == "9df3e2d1-070e-497a-9578-8cc557ced9df" + assert views[2].workbook.name == "Superstore" + assert views[2].workbook.content_url == "Superstore" + assert views[2].workbook.show_tabs + assert views[2].workbook.size == 2 + assert views[2].workbook.created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[2].workbook.updated_at == parse_datetime("2024-02-14T04:42:10Z") + assert views[2].workbook.sheet_count == 9 + assert not views[2].workbook.has_extracts + assert isinstance(views[2].owner, TSC.UserItem) + assert views[2].owner.email == "bob@example.com" + assert views[2].owner.fullname == "Bob" + assert views[2].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert views[2].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert views[2].owner.name == "bob@example.com" + assert views[2].owner.site_role == "SiteAdministratorCreator" + assert isinstance(views[2].project, TSC.ProjectItem) + assert views[2].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[2].project.name == "Samples" + assert views[2].project.description == "This project includes automatically uploaded samples." + assert views[2].total_views == 0 + assert isinstance(views[2].location, TSC.LocationItem) + assert views[2].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[2].location.type == "Project" diff --git a/test/test_workbook.py b/test/test_workbook.py index f3c2dd147..84afd7fcb 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -10,7 +10,7 @@ import pytest import tableauserverclient as TSC -from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.datetime_helpers import format_datetime, parse_datetime from tableauserverclient.models import UserItem, GroupItem, PermissionsRule from tableauserverclient.server.endpoint.exceptions import InternalServerError, UnsupportedAttributeError from tableauserverclient.server.request_factory import RequestFactory @@ -24,6 +24,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") +GET_XML_ALL_FIELDS = os.path.join(TEST_ASSET_DIR, "workbook_get_all_fields.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") @@ -978,3 +979,106 @@ def test_odata_connection(self) -> None: assert xml_connection is not None self.assertEqual(xml_connection.get("serverAddress"), url) + + def test_get_workbook_all_fields(self) -> None: + self.server.version = "3.21" + baseurl = self.server.workbooks.baseurl + + with open(GET_XML_ALL_FIELDS) as f: + response = f.read() + + ro = TSC.RequestOptions() + ro.all_fields = True + + with requests_mock.mock() as m: + m.get(f"{baseurl}?fields=_all_", text=response) + workbooks, _ = self.server.workbooks.get(req_options=ro) + + assert workbooks[0].id == "9df3e2d1-070e-497a-9578-8cc557ced9df" + assert workbooks[0].name == "Superstore" + assert workbooks[0].content_url == "Superstore" + assert workbooks[0].webpage_url == "https://10ax.online.tableau.com/#/site/exampledev/workbooks/265605" + assert workbooks[0].show_tabs + assert workbooks[0].size == 2 + assert workbooks[0].created_at == parse_datetime("2024-02-14T04:42:09Z") + assert workbooks[0].updated_at == parse_datetime("2024-02-14T04:42:10Z") + assert workbooks[0].sheet_count == 9 + assert not workbooks[0].has_extracts + assert not workbooks[0].encrypt_extracts + assert workbooks[0].default_view_id == "2bdcd787-dcc6-4a5d-bc61-2846f1ef4534" + assert workbooks[0].share_description == "Superstore" + assert workbooks[0].last_published_at == parse_datetime("2024-02-14T04:42:09Z") + assert isinstance(workbooks[0].project, TSC.ProjectItem) + assert workbooks[0].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert workbooks[0].project.name == "Samples" + assert workbooks[0].project.description == "This project includes automatically uploaded samples." + assert isinstance(workbooks[0].location, TSC.LocationItem) + assert workbooks[0].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert workbooks[0].location.type == "Project" + assert workbooks[0].location.name == "Samples" + assert isinstance(workbooks[0].owner, TSC.UserItem) + assert workbooks[0].owner.email == "bob@example.com" + assert workbooks[0].owner.fullname == "Bob Smith" + assert workbooks[0].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert workbooks[0].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert workbooks[0].owner.name == "bob@example.com" + assert workbooks[0].owner.site_role == "SiteAdministratorCreator" + assert workbooks[1].id == "6693cb26-9507-4174-ad3e-9de81a18c971" + assert workbooks[1].name == "World Indicators" + assert workbooks[1].content_url == "WorldIndicators" + assert workbooks[1].webpage_url == "https://10ax.online.tableau.com/#/site/exampledev/workbooks/265606" + assert workbooks[1].show_tabs + assert workbooks[1].size == 1 + assert workbooks[1].created_at == parse_datetime("2024-02-14T04:42:11Z") + assert workbooks[1].updated_at == parse_datetime("2024-02-14T04:42:12Z") + assert workbooks[1].sheet_count == 8 + assert not workbooks[1].has_extracts + assert not workbooks[1].encrypt_extracts + assert workbooks[1].default_view_id == "3d10dbcf-a206-47c7-91ba-ebab3ab33d7c" + assert workbooks[1].share_description == "World Indicators" + assert workbooks[1].last_published_at == parse_datetime("2024-02-14T04:42:11Z") + assert isinstance(workbooks[1].project, TSC.ProjectItem) + assert workbooks[1].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert workbooks[1].project.name == "Samples" + assert workbooks[1].project.description == "This project includes automatically uploaded samples." + assert isinstance(workbooks[1].location, TSC.LocationItem) + assert workbooks[1].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert workbooks[1].location.type == "Project" + assert workbooks[1].location.name == "Samples" + assert isinstance(workbooks[1].owner, TSC.UserItem) + assert workbooks[1].owner.email == "bob@example.com" + assert workbooks[1].owner.fullname == "Bob Smith" + assert workbooks[1].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert workbooks[1].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert workbooks[1].owner.name == "bob@example.com" + assert workbooks[1].owner.site_role == "SiteAdministratorCreator" + assert workbooks[2].id == "dbc0f162-909f-4edf-8392-0d12a80af955" + assert workbooks[2].name == "Superstore" + assert workbooks[2].description == "This is a superstore workbook" + assert workbooks[2].content_url == "Superstore_17078880698360" + assert workbooks[2].webpage_url == "https://10ax.online.tableau.com/#/site/exampledev/workbooks/265621" + assert not workbooks[2].show_tabs + assert workbooks[2].size == 1 + assert workbooks[2].created_at == parse_datetime("2024-02-14T05:21:09Z") + assert workbooks[2].updated_at == parse_datetime("2024-07-02T02:19:59Z") + assert workbooks[2].sheet_count == 7 + assert workbooks[2].has_extracts + assert not workbooks[2].encrypt_extracts + assert workbooks[2].default_view_id == "8c4b1d3e-3f31-4d2a-8b9f-492b92f27987" + assert workbooks[2].share_description == "Superstore" + assert workbooks[2].last_published_at == parse_datetime("2024-07-02T02:19:58Z") + assert isinstance(workbooks[2].project, TSC.ProjectItem) + assert workbooks[2].project.id == "9836791c-9468-40f0-b7f3-d10b9562a046" + assert workbooks[2].project.name == "default" + assert workbooks[2].project.description == "The default project that was automatically created by Tableau." + assert isinstance(workbooks[2].location, TSC.LocationItem) + assert workbooks[2].location.id == "9836791c-9468-40f0-b7f3-d10b9562a046" + assert workbooks[2].location.type == "Project" + assert workbooks[2].location.name == "default" + assert isinstance(workbooks[2].owner, TSC.UserItem) + assert workbooks[2].owner.email == "bob@example.com" + assert workbooks[2].owner.fullname == "Bob Smith" + assert workbooks[2].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert workbooks[2].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert workbooks[2].owner.name == "bob@example.com" + assert workbooks[2].owner.site_role == "SiteAdministratorCreator" From c728b19e115b8cf7aac56dcbb8ee131d2d39b78e Mon Sep 17 00:00:00 2001 From: casey-crawford-cfa Date: Fri, 16 May 2025 08:15:44 -0500 Subject: [PATCH 296/296] Tableau API is expecting an integer or --- 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 14cec1878..52fd658c5 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -318,4 +318,4 @@ def interval(self, interval_values): self._interval = interval_values def _interval_type_pairs(self): - return [(IntervalItem.Occurrence.MonthDay, self.interval)] + return [(IntervalItem.Occurrence.MonthDay, str(day)) for day in self.interval]