From 9ec60ac573792f8809b7f74a2bae4b04a2f06a1a Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 6 Apr 2022 12:59:59 -0700 Subject: [PATCH 1/3] 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 9fcb9a1ffde0904c1a40446643a77e3c44da59ee Mon Sep 17 00:00:00 2001 From: Brandon Guest Date: Wed, 20 Apr 2022 17:08:54 -0400 Subject: [PATCH 2/3] make project_id nullable to support "Personal Space" --- tableauserverclient/models/workbook_item.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 949970ced..583d2883c 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -124,7 +124,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 From 7a407c4ab5ea0177173570e28f893c9ae834f300 Mon Sep 17 00:00:00 2001 From: Brandon Guest Date: Thu, 5 May 2022 15:47:08 -0400 Subject: [PATCH 3/3] remove tests for workbook invalid project_id, as it is no longer required to be non-null --- test/test_workbook_model.py | 6 ------ 1 file changed, 6 deletions(-) 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):