diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 000000000..b7a7a926d
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,28 @@
+---
+name: Feature Request
+title: "[REQUEST TYPE] [FEATURE TITLE]"
+about: Suggest a feature that could be added to the client
+labels: enhancement, needs investigation
+---
+
+## Summary
+A one line description of the request. Skip this if the title is already a good summary.
+
+
+## Request Type
+If you know, say which of these types your request is in the title, and follow the suggestions for that type when writing your description.
+
+****Type 1: support a REST API:****
+If it is functionality that already exists in the [REST API](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm), example API calls are the clearest way to explain your request.
+
+****Type 2: add a REST API and support it in tsc.****
+If it is functionality that can be achieved somehow on Tableau Server but not through the REST API, describe the current way to do it. (e.g: functionality that is available in the Web UI, or by using the Hyper API). For UI, screenshots can be helpful.
+
+****Type 3: new functionality****
+Requests for totally new functionality will generally be passed to the relevant dev team, but we probably can't give any useful estimate of how or when it might be implemented. If it is a feature that is 'about' the API or programmable access, here might be the best place to suggest it, but generally feature requests will be more visible in the [Tableau Community Ideas](https://community.tableau.com/s/ideas) forum and should go there instead.
+
+
+## Description
+A clear and concise description of what the feature request is. If you think that the value of this feature might not be obvious, include information like how often it is needed, amount of work saved, etc. If your feature request is related to a file or server in a specific state, describe the starting state when the feature can be used, and the end state after using it. If it involves modifying files, an example file may be helpful.
+
+
diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml
new file mode 100644
index 000000000..70bc845e9
--- /dev/null
+++ b/.github/workflows/code-coverage.yml
@@ -0,0 +1,39 @@
+name: Check Test Coverage
+
+on:
+ pull_request:
+ branches:
+ - development
+
+jobs:
+ build:
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest]
+ python-version: ['3.10']
+
+ runs-on: ${{ matrix.os }}
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e .[test]
+
+ # https://github.com/marketplace/actions/pytest-coverage-comment
+ - name: Generate coverage report
+ run: pytest --junitxml=pytest.xml --cov=tableauserverclient test/ | tee pytest-coverage.txt
+
+ - name: Comment on pull request with coverage
+ continue-on-error: true
+ uses: MishaKav/pytest-coverage-comment@main
+ with:
+ pytest-coverage-path: ./pytest-coverage.txt
diff --git a/.github/workflows/meta-checks.yml b/.github/workflows/meta-checks.yml
new file mode 100644
index 000000000..0e2b425ee
--- /dev/null
+++ b/.github/workflows/meta-checks.yml
@@ -0,0 +1,49 @@
+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:
+ - name: Get pip cache dir
+ id: pip-cache
+ shell: bash
+ run: |
+ echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT
+
+ - name: cache
+ uses: actions/cache@v4
+ with:
+ path: ${{ steps.pip-cache.outputs.dir }}
+ key: ${{ runner.os }}-${{ matrix.python-version }}-pip-${{ hashFiles('pyproject.toml') }}
+ restore-keys: |
+ ${{ runner.os }}-${{ matrix.python-version }}-pip-
+
+ - uses: actions/checkout@v4
+
+ - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }}
+ uses: actions/setup-python@v5
+ 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 --implicit-optional tableauserverclient test
diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml
new file mode 100644
index 000000000..cae0f409c
--- /dev/null
+++ b/.github/workflows/publish-pypi.yml
@@ -0,0 +1,40 @@
+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:
+ tags:
+ - 'v*.*.*'
+
+jobs:
+ build-n-publish:
+ name: Build dist files for PyPi
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - uses: actions/setup-python@v5
+ with:
+ python-version: 3.9
+ - name: Build dist files
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e .[test] build
+ python -m build
+ git describe --tag --dirty --always
+
+ - name: Publish distribution 📦 to Test PyPI # always run
+ uses: pypa/gh-action-pypi-publish@release/v1 # license BSD-2
+ with:
+ password: ${{ secrets.TEST_PYPI_API_TOKEN }}
+ repository_url: https://test.pypi.org/legacy/
+
+ - name: Publish distribution 📦 to PyPI
+ if: ${{ github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v') }}
+ uses: pypa/gh-action-pypi-publish@release/v1 # license BSD-2
+ with:
+ password: ${{ secrets.PYPI_API_TOKEN }}
diff --git a/.github/workflows/pypi-smoke-tests.yml b/.github/workflows/pypi-smoke-tests.yml
new file mode 100644
index 000000000..45ea94400
--- /dev/null
+++ b/.github/workflows/pypi-smoke-tests.yml
@@ -0,0 +1,36 @@
+# This workflow will install TSC from pypi and validate that it runs. For more information see:
+# https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
+
+name: Pypi smoke tests
+
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: 0 11 * * * # Every day at 11AM UTC (7AM EST)
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest, windows-latest]
+ python-version: ['3.x']
+
+ runs-on: ${{ matrix.os }}
+
+ steps:
+ - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: pip install
+ run: |
+ pip uninstall tableauserverclient
+ pip install tableauserverclient
+ - name: Launch app
+ run: |
+ python -c "import tableauserverclient as TSC
+ server = TSC.Server('http://example.com', use_server_version=False)"
diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml
index 61476132f..2e197cf20 100644
--- a/.github/workflows/run-tests.yml
+++ b/.github/workflows/run-tests.yml
@@ -1,6 +1,11 @@
name: Python tests
-on: [push]
+on:
+ pull_request: {}
+ push:
+ branches:
+ - development
+ - master
jobs:
build:
@@ -8,29 +13,43 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
- python-version: [3.6, 3.7, 3.8, 3.9, 3.10.0-rc.2]
+ python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
runs-on: ${{ matrix.os }}
steps:
- - uses: actions/checkout@v2
+ - name: Get pip cache dir
+ id: pip-cache
+ shell: bash
+ run: |
+ echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT
+
+ - name: cache
+ uses: actions/cache@v4
+ with:
+ path: ${{ steps.pip-cache.outputs.dir }}
+ key: ${{ runner.os }}-${{ matrix.python-version }}-pip-${{ hashFiles('pyproject.toml') }}
+ restore-keys: |
+ ${{ runner.os }}-${{ matrix.python-version }}-pip-
+
+ - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }}
- uses: actions/setup-python@v2
+ uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
- pip install -e .[test]
- pip install mypy
+ pip install -e .[test] build
- name: Test with pytest
+ if: always()
run: |
pytest test
- - name: Run Mypy but allow failures
+ - name: Test build
+ if: always()
run: |
- mypy --show-error-codes --disable-error-code misc tableauserverclient
- continue-on-error: true
+ python -m build
diff --git a/.github/workflows/slack.yml b/.github/workflows/slack.yml
index c3b17e8c4..9afebf25b 100644
--- a/.github/workflows/slack.yml
+++ b/.github/workflows/slack.yml
@@ -4,11 +4,13 @@ on: [push, pull_request, issues]
jobs:
slack-notifications:
- runs-on: ubuntu-20.04
+ continue-on-error: true
+ runs-on: ubuntu-latest
name: Sends a message to Slack when a push, a pull request or an issue is made
steps:
- name: Send message to Slack API
- uses: archive/github-actions-slack@v2.2.2
+ continue-on-error: true
+ uses: archive/github-actions-slack@v2.8.0
id: notify
with:
slack-bot-user-oauth-access-token: ${{ secrets.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 5efc6b31d..b3b3ff80f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,6 +24,7 @@ var/
*.egg-info/
.installed.cfg
*.egg
+pip-wheel-metadata/
# PyInstaller
# Usually these files are written by a python script from a template
@@ -78,17 +79,18 @@ target/
# poetry
poetry.lock
-pyproject.toml
# celery beat schedule file
celerybeat-schedule
# dotenv
.env
+env.py
# virtualenv
venv/
ENV/
+.venv/
# Spyder project settings
.spyderproject
@@ -153,3 +155,5 @@ $RECYCLE.BIN/
docs/_site/
docs/.jekyll-metadata
docs/Gemfile.lock
+samples/credentials
+.venv/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e375f8385..c018294d3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,10 +1,27 @@
+
+## 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)
-Update publish.sh to use python3 (#866)
-Fixed jobs.get_by_id(job_id) example & reference docs (#867, #868)
-Fixed handling for workbooks in personal spaces which do not have projectID or Name (#875)
-Updated links to Data Source Methods page in REST API docs (#879)
-Upgraded to newer Slack action provider (#880)
-Added support to the package for getting flow run status, as well as the ability to cancel flow runs. (#884)
+* Added support for accepting parameters for post request of the metadata api (#850)
+* Fixed jobs.get_by_id(job_id) example & reference docs (#867, #868)
+* Fixed handling for workbooks in personal spaces which do not have projectID or Name (#875)
+* Updated links to Data Source Methods page in REST API docs (#879)
+* Unified arguments of sample scripts (#889)
+* Updated docs for - links to Datasource API (#879) , sample scripts (#892) & metadata query (#896)
+* Added support for scheduling DataUpdate Jobs (#891)
+* Exposed the fileuploads API endpoint (#894)
+* Added a new sample & documentation for metadata API (#895, #896)
+* Added support to the package for getting flow run status, as well as the ability to cancel flow runs. (#884)
+* Added jobs.wait_for_job method (#903)
+* Added description support for datasources item (#912)
+* Dropped support for Python 3.5 (#911)
## 0.16.0 (15 July 2021)
* Documentation updates (#800, #818, #839, #842)
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index 89b8d213c..a69cfff21 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -4,59 +4,79 @@ The following people have contributed to this project to make it possible, and w
## Contributors
+* [jacalata](https://github.com/jacalata)
+* [jorwoods](https://github.com/jorwoods)
+* [t8y8](https://github.com/t8y8)
+* [bcantoni](https://github.com/bcantoni)
+* [shinchris](https://github.com/shinchris)
+* [vogelsgesang](https://github.com/vogelsgesang)
+* [lbrendanl](https://github.com/lbrendanl)
+* [LGraber](https://github.com/LGraber)
+* [gaoang2148](https://github.com/gaoang2148)
+* [benlower](https://github.com/benlower)
+* [liu-rebecca](https://github.com/liu-rebecca)
+* [guodah](https://github.com/guodah)
+* [jdomingu](https://github.com/jdomingu)
+* [kykrueger](https://github.com/kykrueger)
+* [jz-huang](https://github.com/jz-huang)
+* [opus-42](https://github.com/opus-42)
+* [markm-io](https://github.com/markm-io)
+* [graysonarts](https://github.com/graysonarts)
+* [d45](https://github.com/d45)
+* [preguraman](https://github.com/preguraman)
+* [sotnich](https://github.com/sotnich)
+* [mmuttreja-tableau](https://github.com/mmuttreja-tableau)
+* [dependabot[bot]](https://github.com/apps/dependabot)
+* [scuml](https://github.com/scuml)
+* [ovinis](https://github.com/ovinis)
+* [FFMMM](https://github.com/FFMMM)
+* [martinbpeters](https://github.com/martinbpeters)
+* [talvalin](https://github.com/talvalin)
+* [dzucker-tab](https://github.com/dzucker-tab)
+* [a-torres-2](https://github.com/a-torres-2)
+* [nnevalainen](https://github.com/nnevalainen)
+* [mbren](https://github.com/mbren)
+* [wolkiewiczk](https://github.com/wolkiewiczk)
+* [jacobj10](https://github.com/jacobj10)
+* [hugoboos](https://github.com/hugoboos)
+* [grbritz](https://github.com/grbritz)
+* [fpagliar](https://github.com/fpagliar)
+* [bskim45](https://github.com/bskim45)
+* [baixin137](https://github.com/baixin137)
+* [jessicachen79](https://github.com/jessicachen79)
+* [gconklin](https://github.com/gconklin)
* [geordielad](https://github.com/geordielad)
-* [Hugo Stijns](https://github.com/hugoboos)
-* [kovner](https://github.com/kovner)
-* [Talvalin](https://github.com/Talvalin)
-* [Chris Toomey](https://github.com/cmtoomey)
-* [Vathsala Achar](https://github.com/VathsalaAchar)
-* [Graeme Britz](https://github.com/grbritz)
-* [Russ Goldin](https://github.com/tagyoureit)
-* [William Lang](https://github.com/williamlang)
-* [Jim Morris](https://github.com/jimbodriven)
-* [BingoDinkus](https://github.com/BingoDinkus)
-* [Sergey Sotnichenko](https://github.com/sotnich)
-* [Bruce Zhang](https://github.com/baixin137)
-* [Bumsoo Kim](https://github.com/bskim45)
+* [fossabot](https://github.com/fossabot)
* [daniel1608](https://github.com/daniel1608)
-* [Joshua Jacob](https://github.com/jacobj10)
-* [Francisco Pagliaricci](https://github.com/fpagliar)
-* [Tomasz Machalski](https://github.com/toomyem)
-* [Jared Dominguez](https://github.com/jdomingu)
-* [Brendan Lee](https://github.com/lbrendanl)
-* [Martin Dertz](https://github.com/martydertz)
-* [Christian Oliff](https://github.com/coliff)
-* [Albin Antony](https://github.com/user9747)
-* [prae04](https://github.com/prae04)
-* [Martin Peters](https://github.com/martinbpeters)
-* [Sherman K](https://github.com/shrmnk)
-* [Jorge Fonseca](https://github.com/JorgeFonseca)
-* [Kacper Wolkiewicz](https://github.com/wolkiewiczk)
-* [Dahai Guo](https://github.com/guodah)
-* [Geraldine Zanolli](https://github.com/illonage)
-* [Jordan Woods](https://github.com/jorwoods)
-* [Reba Magier](https://github.com/rmagier1)
-* [Stephen Mitchell](https://github.com/scuml)
-* [absentmoose](https://github.com/absentmoose)
-* [Paul Vickers](https://github.com/paulvic)
-* [Madhura Selvarajan](https://github.com/maddy-at-leisure)
-* [Niklas Nevalainen](https://github.com/nnevalainen)
-* [Terrence Jones](https://github.com/tjones-commits)
-* [John Vandenberg](https://github.com/jayvdb)
-* [Lee Boynton](https://github.com/lboynton)
* [annematronic](https://github.com/annematronic)
-
-## Core Team
-
-* [Chris Shin](https://github.com/shinchris)
-* [Lee Graber](https://github.com/lgraber)
-* [Tyler Doyle](https://github.com/t8y8)
-* [Russell Hay](https://github.com/RussTheAerialist)
-* [Ben Lower](https://github.com/benlower)
-* [Ang Gao](https://github.com/gaoang2148)
-* [Priya Reguraman](https://github.com/preguraman)
-* [Jac Fitzgerald](https://github.com/jacalata)
-* [Dan Zucker](https://github.com/dzucker-tab)
-* [Brian Cantoni](https://github.com/bcantoni)
-* [Ovini Nanayakkara](https://github.com/ovinis)
-* [Manish Muttreja](https://github.com/mmuttreja-tableau)
+* [rshide](https://github.com/rshide)
+* [VathsalaAchar](https://github.com/VathsalaAchar)
+* [TrimPeachu](https://github.com/TrimPeachu)
+* [ajbosco](https://github.com/ajbosco)
+* [jimbodriven](https://github.com/jimbodriven)
+* [ltiffanydev](https://github.com/ltiffanydev)
+* [martydertz](https://github.com/martydertz)
+* [r-richmond](https://github.com/r-richmond)
+* [sfarr15](https://github.com/sfarr15)
+* [tagyoureit](https://github.com/tagyoureit)
+* [tjones-commits](https://github.com/tjones-commits)
+* [yoshichan5](https://github.com/yoshichan5)
+* [wlodi83](https://github.com/wlodi83)
+* [anipmehta](https://github.com/anipmehta)
+* [cmtoomey](https://github.com/cmtoomey)
+* [pes-magic](https://github.com/pes-magic)
+* [illonage](https://github.com/illonage)
+* [jayvdb](https://github.com/jayvdb)
+* [jorgeFons](https://github.com/jorgeFons)
+* [Kovner](https://github.com/Kovner)
+* [LarsBreddemann](https://github.com/LarsBreddemann)
+* [lboynton](https://github.com/lboynton)
+* [maddy-at-leisure](https://github.com/maddy-at-leisure)
+* [narcolino-tableau](https://github.com/narcolino-tableau)
+* [PatrickfBraz](https://github.com/PatrickfBraz)
+* [paulvic](https://github.com/paulvic)
+* [shrmnk](https://github.com/shrmnk)
+* [TableauKyle](https://github.com/TableauKyle)
+* [bossenti](https://github.com/bossenti)
+* [ma7tcsp](https://github.com/ma7tcsp)
+* [toomyem](https://github.com/toomyem)
diff --git a/LICENSE b/LICENSE
index 6222b2e80..22f90640f 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
The MIT License (MIT)
-Copyright (c) 2016 Tableau
+Copyright (c) 2022 Tableau
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/MANIFEST.in b/MANIFEST.in
index b4b1425f3..9b7512fb9 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,13 +1,14 @@
-include versioneer.py
-include tableauserverclient/_version.py
+include CHANGELOG.md
+include contributing.md
+include CONTRIBUTORS.md
include LICENSE
include LICENSE.versioneer
include README.md
-include CHANGELOG.md
+include tableauserverclient/_version.py
+include versioneer.py
recursive-include docs *.md
recursive-include samples *.py
recursive-include samples *.txt
-recursive-include smoke *.py
recursive-include test *.csv
recursive-include test *.dict
recursive-include test *.hyper
@@ -15,9 +16,7 @@ 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
+recursive-include test *.tde
+global-include *.pyi
+global-include *.typed
diff --git a/README.md b/README.md
index b454dd4c7..5c80f337e 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,7 @@
# Tableau Server Client (Python)
[](https://www.tableau.com/support-levels-it-and-developer-tools) [](https://github.com/tableau/server-client-python/actions)
+[](https://app.fossa.com/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python?ref=badge_shield)
Use the Tableau Server Client (TSC) library to increase your productivity as you interact with the Tableau Server REST API. With the TSC library you can do almost everything that you can do with the REST API, including:
@@ -8,8 +9,14 @@ 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. As of September 2024, support for Python 3.7 and 3.8 will be dropped - support for older versions of Python aims to match https://devguide.python.org/versions/
+
+To see sample code that works directly with the REST API (in Java, Python, or Postman), visit the [REST API Samples](https://github.com/tableau/rest-api-samples) repo.
For more information on installing and using TSC, see the documentation:
+To contribute, see our [Developer Guide](https://tableau.github.io/server-client-python/docs/dev-guide). A list of all our contributors to date is in [CONTRIBUTORS.md].
+
+## License
+[](https://app.fossa.com/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python?ref=badge_large)
diff --git a/contributing.md b/contributing.md
index 3d5cd3d43..a0132919f 100644
--- a/contributing.md
+++ b/contributing.md
@@ -10,9 +10,6 @@ Contribution can include, but are not limited to, any of the following:
* Fix an Issue/Bug
* Add/Fix documentation
-Contributions must follow the guidelines outlined on the [Tableau Organization](http://tableau.github.io/) page, though filing an issue or requesting
-a feature do not require the CLA.
-
## Issues and Feature Requests
To submit an issue/bug report, or to request a feature, please submit a [GitHub issue](https://github.com/tableau/server-client-python/issues) to the repo.
@@ -23,53 +20,6 @@ files to assist in the repro. **Be sure to scrub the files of any potentially s
For a feature request, please try to describe the scenario you are trying to accomplish that requires the feature. This will help us understand
the limitations that you are running into, and provide us with a use case to know if we've satisfied your request.
-### Label usage on Issues
-
-The core team is responsible for assigning most labels to the issue. Labels
-are used for prioritizing the core team's work, and use the following
-definitions for labels.
-
-The following labels are only to be set or changed by the core team:
-
-* **bug** - A bug is an unintended behavior for existing functionality. It only relates to existing functionality and the behavior that is expected with that functionality. We do not use **bug** to indicate priority.
-* **enhancement** - An enhancement is a new piece of functionality and is related to the fact that new code will need to be written in order to close this issue. We do not use **enhancement** to indicate priority.
-* **CLARequired** - This label is used to indicate that the contribution will require that the CLA is signed before we can accept a PR. This label should not be used on Issues
-* **CLANotRequired** - This label is used to indicate that the contribution does not require a CLA to be signed. This is used for minor fixes and usually around doc fixes or correcting strings.
-* **help wanted** - This label on an issue indicates it's a good choice for external contributors to take on. It usually means it's an issue that can be tackled by first time contributors.
-
-The following labels can be used by the issue creator or anyone in the
-community to help us prioritize enhancement and bug fixes that are
-causing pain from our users. The short of it is, purple tags are ones that
-anyone can add to an issue:
-
-* **Critical** - This means that you won't be able to use the library until the issues have been resolved. If an issue is already labeled as critical, but you want to show your support for it, add a +1 comment to the issue. This helps us know what issues are really impacting our users.
-* **Nice To Have** - This means that the issue doesn't block your usage of the library, but would make your life easier. Like with critical, if the issue is already tagged with this, but you want to show your support, add a +1 comment to the issue.
-
-## Fixes, Implementations, and Documentation
-
-For all other things, please submit a PR that includes the fix, documentation, or new code that you are trying to contribute. More information on
-creating a PR can be found in the [Development Guide](https://tableau.github.io/server-client-python/docs/dev-guide).
-
-If the feature is complex or has multiple solutions that could be equally appropriate approaches, it would be helpful to file an issue to discuss the
-design trade-offs of each solution before implementing, to allow us to collectively arrive at the best solution, which most likely exists in the middle
-somewhere.
-
-## Getting Started
-
-```shell
-pip install versioneer
-python setup.py build
-python setup.py test
-```
-
-### 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
+### Making Contributions
-# this will format the directory and code for you
-black --line-length 120 tableauserverclient
-```
+Refer to the [Developer Guide](https://tableau.github.io/server-client-python/docs/dev-guide) which explains how to make contributions to the TSC project.
diff --git a/getcontributors.py b/getcontributors.py
new file mode 100644
index 000000000..54ca81cb2
--- /dev/null
+++ b/getcontributors.py
@@ -0,0 +1,9 @@
+import json
+import requests
+
+
+logins = json.loads(
+ requests.get("https://api.github.com/repos/tableau/server-client-python/contributors?per_page=200").text
+)
+for login in logins:
+ print(f"* [{login["login"]}]({login["html_url"]})")
diff --git a/publish.sh b/publish.sh
index 02812c1c3..46d54a1ee 100755
--- a/publish.sh
+++ b/publish.sh
@@ -1,8 +1,11 @@
#!/usr/bin/env bash
+# tag the release version and confirm a clean version number
+git tag vxxxx
+git describe --tag --dirty --always
+
set -e
rm -rf dist
-python3 setup.py sdist
-python3 setup.py bdist_wheel
+python setup.py sdist bdist_wheel
twine upload dist/*
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 000000000..68f7589ca
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,65 @@
+[build-system]
+requires = ["setuptools>=75.0", "versioneer[toml]==0.29", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name="tableauserverclient"
+
+dynamic = ["version"]
+description='A Python module for working with the Tableau Server REST API.'
+authors = [{name="Tableau", email="github@tableau.com"}]
+license = {file = "LICENSE"}
+readme = "README.md"
+
+dependencies = [
+ 'defusedxml>=0.7.1', # latest as at 7/31/23
+ 'packaging>=23.1', # latest as at 7/31/23
+ 'requests>=2.32', # latest as at 7/31/23
+ 'urllib3>=2.2.2,<3',
+ 'typing_extensions>=4.0',
+]
+requires-python = ">=3.9"
+classifiers = [
+ "Programming Language :: Python",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.9",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13"
+]
+[project.urls]
+repository = "https://github.com/tableau/server-client-python"
+
+[project.optional-dependencies]
+test = ["black==24.8", "build", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests",
+ "requests-mock>=1.0,<2.0"]
+
+[tool.black]
+line-length = 120
+target-version = ['py39', 'py310', 'py311', 'py312', 'py313']
+force-exclude = "tableauserverclient/bin/*"
+
+[tool.mypy]
+check_untyped_defs = false
+disable_error_code = [
+ 'misc',
+ 'annotation-unchecked' # can be removed when check_untyped_defs = true
+]
+files = ["tableauserverclient", "test", "samples"]
+show_error_codes = true
+ignore_missing_imports = true # defusedxml library has no types
+no_implicit_reexport = true
+implicit_optional = true
+exclude = ['/bin/']
+
+[tool.pytest.ini_options]
+testpaths = ["test"]
+addopts = "--junitxml=./test.junit.xml"
+
+[tool.versioneer]
+VCS = "git"
+style = "pep440-pre"
+versionfile_source = "tableauserverclient/bin/_version.py"
+versionfile_build = "tableauserverclient/bin/_version.py"
+tag_prefix = "v"
diff --git a/samples/add_default_permission.py b/samples/add_default_permission.py
index 8018c7b30..d26d009e2 100644
--- a/samples/add_default_permission.py
+++ b/samples/add_default_permission.py
@@ -1,6 +1,6 @@
####
# This script demonstrates how to add default permissions using TSC
-# To run the script, you must have installed Python 3.6 or later.
+# To run the script, you must have installed Python 3.7 or later.
#
# In order to demonstrate adding a new default permission, this sample will create
# a new project and add a new capability to the new project, for the default "All users" group.
@@ -16,16 +16,19 @@
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", help="server address")
+ parser.add_argument("--site", "-S", help="site name")
+ parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server")
+ parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
+ parser.add_argument(
+ "--logging-level",
+ "-l",
+ choices=["debug", "info", "error"],
+ default="error",
+ help="desired logging level (set to error by default)",
+ )
# Options specific to this sample
# This sample has no additional options, yet. If you add some, please add them here
@@ -39,7 +42,6 @@ def main():
tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site)
server = TSC.Server(args.server, use_server_version=True)
with server.auth.sign_in(tableau_auth):
-
# Create a sample project
project = TSC.ProjectItem("sample_project")
project = server.projects.create(project)
@@ -53,10 +55,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)
@@ -64,10 +63,10 @@ def main():
for permission in new_default_permissions:
grantee = permission.grantee
capabilities = permission.capabilities
- print("\nCapabilities for {0} {1}:".format(grantee.tag_name, grantee.id))
+ print(f"\nCapabilities for {grantee.tag_name} {grantee.id}:")
for capability in capabilities:
- print("\t{0} - {1}".format(capability, capabilities[capability]))
+ print(f"\t{capability} - {capabilities[capability]}")
# Uncomment lines below to DELETE the new capability and the new project
# rules_to_delete = TSC.PermissionsRule(
@@ -78,5 +77,5 @@ def main():
# server.projects.delete(project.id)
-if __name__ == '__main__':
+if __name__ == "__main__":
main()
diff --git a/samples/create_extract_task.py b/samples/create_extract_task.py
new file mode 100644
index 000000000..8c02fefff
--- /dev/null
+++ b/samples/create_extract_task.py
@@ -0,0 +1,89 @@
+####
+# This script demonstrates how to create extract tasks in Tableau Cloud
+# using the Tableau Server Client.
+#
+# To run the script, you must have installed Python 3.7 or later.
+####
+
+
+import argparse
+import logging
+
+from datetime import time
+
+import tableauserverclient as TSC
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Creates sample extract refresh task.")
+ # Common options; please keep those in sync across all samples
+ parser.add_argument("--server", "-s", help="server address")
+ parser.add_argument("--site", "-S", help="site name")
+ parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server")
+ parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
+ parser.add_argument(
+ "--logging-level",
+ "-l",
+ choices=["debug", "info", "error"],
+ default="error",
+ help="desired logging level (set to error by default)",
+ )
+ # Options specific to this sample:
+ parser.add_argument("resource_type", choices=["workbook", "datasource"])
+ parser.add_argument("resource_id")
+ parser.add_argument("--incremental", default=False)
+
+ args = parser.parse_args()
+
+ # Set logging level based on user input, or error by default
+ logging_level = getattr(logging, args.logging_level.upper())
+ logging.basicConfig(level=logging_level)
+
+ tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site)
+ server = TSC.Server(args.server, use_server_version=False)
+ server.add_http_options({"verify": False})
+ server.use_server_version()
+ with server.auth.sign_in(tableau_auth):
+ # Monthly Schedule
+ # This schedule will run on the 15th of every month at 11:30PM
+ monthly_interval = TSC.MonthlyInterval(start_time=time(23, 30), interval_value=15)
+ print(monthly_interval)
+ monthly_schedule = TSC.ScheduleItem(
+ None,
+ None,
+ None,
+ None,
+ monthly_interval,
+ )
+
+ my_workbook: TSC.WorkbookItem = server.workbooks.get_by_id(args.resource_id)
+
+ target_item = TSC.Target(
+ my_workbook.id, # the id of the workbook or datasource
+ "workbook", # alternatively can be "datasource"
+ )
+
+ refresh_type = "FullRefresh"
+ if args.incremental:
+ refresh_type = "Incremental"
+
+ scheduled_extract_item = TSC.TaskItem(
+ None,
+ refresh_type,
+ None,
+ None,
+ None,
+ monthly_schedule,
+ None,
+ target_item,
+ )
+
+ try:
+ response = server.tasks.create(scheduled_extract_item)
+ print(response)
+ except Exception as e:
+ print(e)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/samples/create_group.py b/samples/create_group.py
index ad0e6cc4f..aca3e895b 100644
--- a/samples/create_group.py
+++ b/samples/create_group.py
@@ -2,33 +2,37 @@
# This script demonstrates how to create a group using the Tableau
# Server Client.
#
-# To run the script, you must have installed Python 3.6 or later.
+# To run the script, you must have installed Python 3.7 or later.
####
import argparse
import logging
+import os
from datetime import time
import tableauserverclient as TSC
+from tableauserverclient import ServerResponseError
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", help="server address")
+ parser.add_argument("--site", "-S", help="site name")
+ parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server")
+ parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
+ parser.add_argument(
+ "--logging-level",
+ "-l",
+ choices=["debug", "info", "error"],
+ default="error",
+ help="desired logging level (set to error by default)",
+ )
# Options specific to this sample
# This sample has no additional options, yet. If you add some, please add them here
-
+ parser.add_argument("--file", help="csv file containing user info", required=False)
args = parser.parse_args()
# Set logging level based on user input, or error by default
@@ -36,12 +40,51 @@ def main():
logging.basicConfig(level=logging_level)
tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site)
- server = TSC.Server(args.server, use_server_version=True)
+ server = TSC.Server(args.server, use_server_version=True, http_options={"verify": False})
with server.auth.sign_in(tableau_auth):
- group = TSC.GroupItem('test')
- group = server.groups.create(group)
- print(group)
+ # this code shows 3 different error codes that mean "resource is already in collection"
+ # 409009: group already exists on server
+ # 409107: user is already on site
+ # 409011: user is already in group
+
+ group = TSC.GroupItem("test")
+ try:
+ group = server.groups.create(group)
+ except TSC.server.endpoint.exceptions.ServerResponseError as rError:
+ if rError.code == "409009":
+ print("Group already exists")
+ group = server.groups.filter(name=group.name)[0]
+ else:
+ raise rError
+ server.groups.populate_users(group)
+ for user in group.users:
+ print(user.name)
+
+ if args.file:
+ filepath = os.path.abspath(args.file)
+ print(f"Add users to site from file {filepath}:")
+ added: list[TSC.UserItem]
+ failed: list[TSC.UserItem, TSC.ServerResponseError]
+ added, failed = server.users.create_from_file(filepath)
+ for user, error in failed:
+ print(user, error.code)
+ if error.code == "409017":
+ user = server.users.filter(name=user.name)[0]
+ added.append(user)
+ print(f"Adding users to group:{added}")
+ for user in added:
+ print(f"Adding user {user}")
+ try:
+ server.groups.add_user(group, user.id)
+ except ServerResponseError as serverError:
+ if serverError.code == "409011":
+ print(f"user {user.name} is already a member of group {group.name}")
+ else:
+ raise rError
+
+ for user in group.users:
+ print(user.name)
-if __name__ == '__main__':
+if __name__ == "__main__":
main()
diff --git a/samples/create_project.py b/samples/create_project.py
index 814d35617..d775902aa 100644
--- a/samples/create_project.py
+++ b/samples/create_project.py
@@ -4,7 +4,7 @@
# parent_id.
#
#
-# To run the script, you must have installed Python 3.6 or later.
+# To run the script, you must have installed Python 3.7 or later.
####
import argparse
@@ -14,27 +14,31 @@
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)
- sys.exit(1)
+ print("We have already created this project: %s" % project_item.name)
+ project_items = server.projects.filter(name=project_item.name)
+ return project_items[0]
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", help="server address")
+ parser.add_argument("--site", "-S", help="site name")
+ parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server")
+ parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
+ parser.add_argument(
+ "--logging-level",
+ "-l",
+ choices=["debug", "info", "error"],
+ default="error",
+ help="desired logging level (set to error by default)",
+ )
# Options specific to this sample
# This sample has no additional options, yet. If you add some, please add them here
@@ -45,23 +49,50 @@ 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, http_options={"verify": False})
+
+ 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')
+ # With the publish-samples attribute, the project will be created with sample items
+ top_level_project = TSC.ProjectItem(
+ name="Top Level Project",
+ description="A sample tsc project",
+ content_permissions=None,
+ parent_id=None,
+ samples=True,
+ )
top_level_project = create_project(server, top_level_project)
# Specifying parent_id creates a nested projects.
- 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)
+
+ server.projects.populate_workbook_default_permissions(changed_project),
+ server.projects.populate_flow_default_permissions(changed_project),
+ server.projects.populate_lens_default_permissions(changed_project), # uses same as workbook
+ server.projects.populate_datasource_default_permissions(changed_project),
+ server.projects.populate_permissions(changed_project)
+ # Projects have default permissions set for the object types they contain
+ print(f"Permissions from project {changed_project.id}:")
+ print(changed_project.permissions)
+ print(
+ changed_project.default_workbook_permissions,
+ changed_project.default_datasource_permissions,
+ changed_project.default_lens_permissions,
+ changed_project.default_flow_permissions,
+ )
+
-if __name__ == '__main__':
+if __name__ == "__main__":
main()
diff --git a/samples/create_schedules.py b/samples/create_schedules.py
index 39332713b..c23a2eced 100644
--- a/samples/create_schedules.py
+++ b/samples/create_schedules.py
@@ -2,7 +2,7 @@
# This script demonstrates how to create schedules using the Tableau
# Server Client.
#
-# To run the script, you must have installed Python 3.6 or later.
+# To run the script, you must have installed Python 3.7 or later.
####
@@ -15,18 +15,20 @@
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", help="server address")
+ parser.add_argument("--site", "-S", help="site name")
+ parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server")
+ parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
+ parser.add_argument(
+ "--logging-level",
+ "-l",
+ choices=["debug", "info", "error"],
+ default="error",
+ help="desired logging level (set to error by default)",
+ )
+ # Options specific to this sample:
# This sample has no additional options, yet. If you add some, please add them here
args = parser.parse_args()
@@ -36,47 +38,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(f"Hourly schedule created (ID: {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(f"Daily schedule created (ID: {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(f"Weekly schedule created (ID: {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(f"Monthly schedule created (ID: {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
deleted file mode 100644
index 3ac2ed4d5..000000000
--- a/samples/download_view_image.py
+++ /dev/null
@@ -1,69 +0,0 @@
-####
-# This script demonstrates how to use the Tableau Server Client
-# to download a high resolution image of a view from Tableau Server.
-#
-# For more information, refer to the documentations on 'Query View Image'
-# (https://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm)
-#
-# To run the script, you must have installed Python 3.6 or later.
-####
-
-import argparse
-import logging
-
-import tableauserverclient as TSC
-
-
-def main():
-
- parser = argparse.ArgumentParser(description='Download image of a specified view.')
- # Common options; please keep those in sync across all samples
- parser.add_argument('--server', '-s', required=True, help='server address')
- parser.add_argument('--site', '-S', help='site name')
- parser.add_argument('--token-name', '-p', required=True,
- help='name of the personal access token used to sign into the server')
- parser.add_argument('--token-value', '-v', required=True,
- help='value of the personal access token used to sign into the server')
- parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error',
- help='desired logging level (set to error by default)')
- # Options specific to this sample
- parser.add_argument('--view-name', '-vn', required=True,
- help='name of view to download an image of')
- parser.add_argument('--filepath', '-f', required=True, help='filepath to save the image returned')
- parser.add_argument('--maxage', '-m', required=False, help='max age of the image in the cache in minutes.')
-
- args = parser.parse_args()
-
- # Set logging level based on user input, or error by default
- logging_level = getattr(logging, args.logging_level.upper())
- logging.basicConfig(level=logging_level)
-
- # Step 1: Sign in to server.
- tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site)
- server = TSC.Server(args.server, use_server_version=True)
- with server.auth.sign_in(tableau_auth):
- # Step 2: Query for the view that we want an image of
- req_option = TSC.RequestOptions()
- req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name,
- TSC.RequestOptions.Operator.Equals, args.view_name))
- all_views, pagination_item = server.views.get(req_option)
- if not all_views:
- raise LookupError("View with the specified name was not found.")
- view_item = all_views[0]
-
- max_age = args.maxage
- if not max_age:
- max_age = 1
-
- image_req_option = TSC.ImageRequestOptions(imageresolution=TSC.ImageRequestOptions.Resolution.High,
- maxage=max_age)
- server.views.populate_image(view_item, image_req_option)
-
- with open(args.filepath, "wb") as image_file:
- image_file.write(view_item.image)
-
- print("View image saved to {0}".format(args.filepath))
-
-
-if __name__ == '__main__':
- main()
diff --git a/samples/explore_datasource.py b/samples/explore_datasource.py
index a78345122..c9f35d5be 100644
--- a/samples/explore_datasource.py
+++ b/samples/explore_datasource.py
@@ -16,20 +16,22 @@
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", help="server address")
+ parser.add_argument("--site", "-S", help="site name")
+ parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server")
+ parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
+ parser.add_argument(
+ "--logging-level",
+ "-l",
+ choices=["debug", "info", "error"],
+ default="error",
+ help="desired logging level (set to error by default)",
+ )
# Options specific to this sample
- 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()
@@ -49,15 +51,17 @@ def main():
if args.publish:
if default_project is not None:
new_datasource = TSC.DatasourceItem(default_project.id)
+ new_datasource.description = "Published with a description"
new_datasource = server.datasources.publish(
- new_datasource, args.publish, TSC.Server.PublishMode.Overwrite)
- print("Datasource published. ID: {}".format(new_datasource.id))
+ new_datasource, args.publish, TSC.Server.PublishMode.Overwrite
+ )
+ print(f"Datasource published. ID: {new_datasource.id}")
else:
print("Publish failed. Could not find the default project.")
# Gets all datasource items
all_datasources, pagination_item = server.datasources.get()
- print("\nThere are {} datasources on site: ".format(pagination_item.total_available))
+ print(f"\nThere are {pagination_item.total_available} datasources on site: ")
print([datasource.name for datasource in all_datasources])
if all_datasources:
@@ -66,21 +70,24 @@ def main():
# Populate connections
server.datasources.populate_connections(sample_datasource)
- print("\nConnections for {}: ".format(sample_datasource.name))
- print(["{0}({1})".format(connection.id, connection.datasource_name)
- for connection in sample_datasource.connections])
+ print(f"\nConnections for {sample_datasource.name}: ")
+ print([f"{connection.id}({connection.datasource_name})" for connection in sample_datasource.connections])
+
+ # Demonstrate that description is editable
+ sample_datasource.description = "Description updated by TSC"
+ server.datasources.update(sample_datasource)
# Add some tags to the datasource
original_tag_set = set(sample_datasource.tags)
- sample_datasource.tags.update('a', 'b', 'c', 'd')
+ sample_datasource.tags.update("a", "b", "c", "d")
server.datasources.update(sample_datasource)
- print("\nOld tag set: {}".format(original_tag_set))
- print("New tag set: {}".format(sample_datasource.tags))
+ print(f"\nOld tag set: {original_tag_set}")
+ print(f"New tag set: {sample_datasource.tags}")
# Delete all tags that were added by setting tags to original
sample_datasource.tags = original_tag_set
server.datasources.update(sample_datasource)
-if __name__ == '__main__':
+if __name__ == "__main__":
main()
diff --git a/samples/explore_favorites.py b/samples/explore_favorites.py
new file mode 100644
index 000000000..f199522ed
--- /dev/null
+++ b/samples/explore_favorites.py
@@ -0,0 +1,83 @@
+# This script demonstrates how to get all favorites, or add/delete a favorite.
+
+import argparse
+import logging
+import tableauserverclient as TSC
+from tableauserverclient.models import Resource
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Explore favoriting functions supported by the Server API.")
+ # Common options; please keep those in sync across all samples
+ parser.add_argument("--server", "-s", help="server address")
+ parser.add_argument("--site", "-S", help="site name")
+ parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server")
+ parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
+ parser.add_argument(
+ "--logging-level",
+ "-l",
+ choices=["debug", "info", "error"],
+ default="error",
+ help="desired logging level (set to error by default)",
+ )
+
+ args = parser.parse_args()
+
+ # Set logging level based on user input, or error by default
+ logging_level = getattr(logging, args.logging_level.upper())
+ logging.basicConfig(level=logging_level)
+
+ # SIGN IN
+ tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site)
+ server = TSC.Server(args.server, use_server_version=True)
+ with server.auth.sign_in(tableau_auth):
+ print(server)
+ my_workbook = None
+ my_view = None
+ my_datasource = None
+
+ # get all favorites on site for the logged on user
+ user: TSC.UserItem = TSC.UserItem()
+ user.id = server.user_id
+ print(f"Favorites for user: {user.id}")
+ server.favorites.get(user)
+ print(user.favorites)
+
+ # get list of workbooks
+ all_workbook_items, pagination_item = server.workbooks.get()
+ if all_workbook_items is not None and len(all_workbook_items) > 0:
+ my_workbook = all_workbook_items[0]
+ server.favorites.add_favorite(user, Resource.Workbook, all_workbook_items[0])
+ print(
+ "Workbook added to favorites. Workbook Name: {}, Workbook ID: {}".format(
+ my_workbook.name, my_workbook.id
+ )
+ )
+ views = server.workbooks.populate_views(my_workbook)
+ if views is not None and len(views) > 0:
+ my_view = views[0]
+ server.favorites.add_favorite_view(user, my_view)
+ print(f"View added to favorites. View Name: {my_view.name}, View ID: {my_view.id}")
+
+ all_datasource_items, pagination_item = server.datasources.get()
+ if all_datasource_items:
+ my_datasource = all_datasource_items[0]
+ server.favorites.add_favorite_datasource(user, my_datasource)
+ print(
+ "Datasource added to favorites. Datasource Name: {}, Datasource ID: {}".format(
+ my_datasource.name, my_datasource.id
+ )
+ )
+
+ server.favorites.delete_favorite_workbook(user, my_workbook)
+ print(f"Workbook deleted from favorites. Workbook Name: {my_workbook.name}, Workbook ID: {my_workbook.id}")
+
+ server.favorites.delete_favorite_view(user, my_view)
+ print(f"View deleted from favorites. View Name: {my_view.name}, View ID: {my_view.id}")
+
+ server.favorites.delete_favorite_datasource(user, my_datasource)
+ print(
+ "Datasource deleted from favorites. Datasource Name: {}, Datasource ID: {}".format(
+ my_datasource.name, my_datasource.id
+ )
+ )
diff --git a/samples/explore_site.py b/samples/explore_site.py
new file mode 100644
index 000000000..eb9eba0de
--- /dev/null
+++ b/samples/explore_site.py
@@ -0,0 +1,78 @@
+####
+# This script demonstrates how to use the Tableau Server Client
+# to interact with sites.
+####
+
+import argparse
+import logging
+import os.path
+import sys
+
+import tableauserverclient as TSC
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Explore site updates by the Server API.")
+ # Common options; please keep those in sync across all samples
+ parser.add_argument("--server", "-s", help="server address")
+ parser.add_argument("--site", "-S", help="site name")
+ parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server")
+ parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
+ parser.add_argument(
+ "--logging-level",
+ "-l",
+ choices=["debug", "info", "error"],
+ default="error",
+ help="desired logging level (set to error by default)",
+ )
+
+ parser.add_argument("--delete")
+ parser.add_argument("--create")
+ parser.add_argument("--url")
+ parser.add_argument("--new_site_name")
+ parser.add_argument("--user_quota")
+ parser.add_argument("--storage_quota")
+ parser.add_argument("--status")
+
+ args = parser.parse_args()
+
+ # Set logging level based on user input, or error by default
+ logging_level = getattr(logging, args.logging_level.upper())
+ logging.basicConfig(level=logging_level)
+
+ # SIGN IN
+ tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site)
+ server = TSC.Server(args.server, use_server_version=True)
+ new_site = None
+ with server.auth.sign_in(tableau_auth):
+ current_site = server.sites.get_by_id(server.site_id)
+
+ if args.delete:
+ print("You can only delete the site you are currently in")
+ print(f"Delete site `{current_site.name}`?")
+ # server.sites.delete(server.site_id)
+
+ elif args.create:
+ new_site = TSC.SiteItem(args.create, args.url or args.create)
+ site_item = server.sites.create(new_site)
+ print(site_item)
+ # to do anything further with the site, you need to log into it
+ # if a PAT is required, that means going to the UI to create one
+
+ else:
+ new_site = current_site
+ print(current_site, "current user quota:", current_site.user_quota)
+ print("Remember, you can only update the site you are currently in")
+ if args.url:
+ new_site.content_url = args.url
+ if args.user_quota:
+ new_site.user_quota = args.user_quota
+ try:
+ updated_site = server.sites.update(new_site)
+ print(updated_site, "new user quota:", updated_site.user_quota)
+ except TSC.ServerResponseError as e:
+ print(e)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/samples/explore_webhooks.py b/samples/explore_webhooks.py
index 50c677cba..f25c41849 100644
--- a/samples/explore_webhooks.py
+++ b/samples/explore_webhooks.py
@@ -17,20 +17,22 @@
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", help="server address")
+ parser.add_argument("--site", "-S", help="site name")
+ parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server")
+ parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
+ parser.add_argument(
+ "--logging-level",
+ "-l",
+ choices=["debug", "info", "error"],
+ default="error",
+ help="desired logging level (set to error by default)",
+ )
# Options specific to this sample
- 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()
@@ -42,33 +44,31 @@ def main():
tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site)
server = TSC.Server(args.server, use_server_version=True)
with server.auth.sign_in(tableau_auth):
-
# Create webhook if create flag is set (-create, -c)
if args.create:
-
new_webhook = TSC.WebhookItem()
new_webhook.name = args.create
new_webhook.url = "https://ifttt.com/maker-url"
new_webhook.event = "datasource-created"
print(new_webhook)
new_webhook = server.webhooks.create(new_webhook)
- print("Webhook created. ID: {}".format(new_webhook.id))
+ print(f"Webhook created. ID: {new_webhook.id}")
# Gets all webhook items
all_webhooks, pagination_item = server.webhooks.get()
- print("\nThere are {} webhooks on site: ".format(pagination_item.total_available))
+ print(f"\nThere are {pagination_item.total_available} webhooks on site: ")
print([webhook.name for webhook in all_webhooks])
if all_webhooks:
# 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..f51639ab3 100644
--- a/samples/explore_workbook.py
+++ b/samples/explore_workbook.py
@@ -17,22 +17,28 @@
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", help="server address")
+ parser.add_argument("--site", "-S", help="site name")
+ parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server")
+ parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
+ parser.add_argument(
+ "--logging-level",
+ "-l",
+ choices=["debug", "info", "error"],
+ default="error",
+ help="desired logging level (set to error by default)",
+ )
# Options specific to this sample
- 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"
+ )
+ parser.add_argument(
+ "--powerpoint", "-ppt", metavar="FILENAME", help="filename (a .ppt file) to save the powerpoint deck"
+ )
args = parser.parse_args()
@@ -44,7 +50,6 @@ def main():
tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site)
server = TSC.Server(args.server, use_server_version=True)
with server.auth.sign_in(tableau_auth):
-
# Publish workbook if publish flag is set (-publish, -p)
overwrite_true = TSC.Server.PublishMode.Overwrite
if args.publish:
@@ -54,38 +59,41 @@ def main():
if default_project is not None:
new_workbook = TSC.WorkbookItem(default_project.id)
new_workbook = server.workbooks.publish(new_workbook, args.publish, overwrite_true)
- print("Workbook published. ID: {}".format(new_workbook.id))
+ print(f"Workbook published. ID: {new_workbook.id}")
else:
- print('Publish failed. Could not find the default project.')
+ print("Publish failed. Could not find the default project.")
# Gets all workbook items
all_workbooks, pagination_item = server.workbooks.get()
- print("\nThere are {} workbooks on site: ".format(pagination_item.total_available))
+ print(f"\nThere are {pagination_item.total_available} workbooks on site: ")
print([workbook.name for workbook in all_workbooks])
if all_workbooks:
# Pick one workbook from the list
sample_workbook = all_workbooks[0]
+ sample_workbook.name = "Name me something cooler"
+ sample_workbook.description = "That doesn't work"
+ updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook)
+ print(updated.name, updated.description)
# Populate views
server.workbooks.populate_views(sample_workbook)
- print("\nName of views in {}: ".format(sample_workbook.name))
+ print(f"\nName of views in {sample_workbook.name}: ")
print([view.name for view in sample_workbook.views])
# Populate connections
server.workbooks.populate_connections(sample_workbook)
- print("\nConnections for {}: ".format(sample_workbook.name))
- print(["{0}({1})".format(connection.id, connection.datasource_name)
- for connection in sample_workbook.connections])
+ print(f"\nConnections for {sample_workbook.name}: ")
+ print([f"{connection.id}({connection.datasource_name})" for connection in sample_workbook.connections])
# Update tags and show_tabs flag
original_tag_set = set(sample_workbook.tags)
- sample_workbook.tags.update('a', 'b', 'c', 'd')
+ sample_workbook.tags.update("a", "b", "c", "d")
sample_workbook.show_tabs = True
server.workbooks.update(sample_workbook)
- print("\nWorkbook's old tag set: {}".format(original_tag_set))
- print("Workbook's new tag set: {}".format(sample_workbook.tags))
- print("Workbook tabbed: {}".format(sample_workbook.show_tabs))
+ print(f"\nWorkbook's old tag set: {original_tag_set}")
+ print(f"Workbook's new tag set: {sample_workbook.tags}")
+ print(f"Workbook tabbed: {sample_workbook.show_tabs}")
# Delete all tags that were added by setting tags to original
sample_workbook.tags = original_tag_set
@@ -96,8 +104,8 @@ def main():
original_tag_set = set(sample_view.tags)
sample_view.tags.add("view_tag")
server.views.update(sample_view)
- print("\nView's old tag set: {}".format(original_tag_set))
- print("View's new tag set: {}".format(sample_view.tags))
+ print(f"\nView's old tag set: {original_tag_set}")
+ print(f"View's new tag set: {sample_view.tags}")
# Delete tag from just one view
sample_view.tags = original_tag_set
@@ -106,15 +114,47 @@ def main():
if args.download:
# Download
path = server.workbooks.download(sample_workbook.id, args.download)
- print("\nDownloaded workbook to {}".format(path))
+ print(f"\nDownloaded workbook to {path}")
if args.preview_image:
# Populate workbook preview image
server.workbooks.populate_preview_image(sample_workbook)
- with open(args.preview_image, 'wb') as f:
+ with open(args.preview_image, "wb") as f:
f.write(sample_workbook.preview_image)
- print("\nDownloaded preview image of workbook to {}".format(os.path.abspath(args.preview_image)))
+ print(f"\nDownloaded preview image of workbook to {os.path.abspath(args.preview_image)}")
+
+ # get custom views
+ cvs, _ = server.custom_views.get()
+ for c in cvs:
+ print(c)
+
+ # for the last custom view in the list
+
+ # update the name
+ # note that this will fail if the name is already changed to this value
+ changed: TSC.CustomViewItem(id=c.id, name="I was updated by tsc")
+ verified_change = server.custom_views.update(changed)
+ print(verified_change)
+
+ # export as image. Filters etc could be added here as usual
+ server.custom_views.populate_image(c)
+ filename = c.id + "-image-export.png"
+ with open(filename, "wb") as f:
+ f.write(c.image)
+ print("saved to " + filename)
+
+ if args.powerpoint:
+ # Populate workbook preview image
+ server.workbooks.populate_powerpoint(sample_workbook)
+ with open(args.powerpoint, "wb") as f:
+ f.write(sample_workbook.powerpoint)
+ print(f"\nDownloaded powerpoint of workbook to {os.path.abspath(args.powerpoint)}")
+
+ if args.delete:
+ print(f"deleting {c.id}")
+ unlucky = TSC.CustomViewItem(c.id)
+ server.custom_views.delete(unlucky.id)
-if __name__ == '__main__':
+if __name__ == "__main__":
main()
diff --git a/samples/export.py b/samples/export.py
index 6317ec53b..b2506cf46 100644
--- a/samples/export.py
+++ b/samples/export.py
@@ -2,7 +2,7 @@
# This script demonstrates how to export a view using the Tableau
# Server Client.
#
-# To run the script, you must have installed Python 3.6 or later.
+# To run the script, you must have installed Python 3.7 or later.
####
import argparse
@@ -12,29 +12,40 @@
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", help="server address")
+ parser.add_argument("--site", "-S", help="site name")
+ parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server")
+ parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
+ parser.add_argument(
+ "--logging-level",
+ "-l",
+ choices=["debug", "info", "error"],
+ default="error",
+ help="desired logging level (set to error by default)",
+ )
# Options specific to this sample
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'))
-
- 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')
+ 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")
+ )
+ # other options shown in explore_workbooks: workbook.download, workbook.preview_image
+ parser.add_argument(
+ "--language", help="Text such as 'Average' will appear in this language. Use values like fr, de, es, en"
+ )
+ parser.add_argument("--workbook", action="store_true")
+ parser.add_argument("--custom_view", action="store_true")
+
+ parser.add_argument("--file", "-f", help="filename to store the exported data")
+ parser.add_argument("--filter", "-vf", metavar="COLUMN:VALUE", help="View filter to apply to the view")
+ parser.add_argument("resource_id", help="LUID for the view or workbook")
args = parser.parse_args()
@@ -43,36 +54,54 @@ def main():
logging.basicConfig(level=logging_level)
tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site)
- server = TSC.Server(args.server, use_server_version=True)
+ server = TSC.Server(args.server, use_server_version=True, http_options={"verify": False})
with server.auth.sign_in(tableau_auth):
- views = filter(lambda x: x.id == args.resource_id,
- TSC.Pager(server.views.get))
- view = views.pop()
+ print("Connected")
+ if args.workbook:
+ item = server.workbooks.get_by_id(args.resource_id)
+ elif args.custom_view:
+ item = server.custom_views.get_by_id(args.resource_id)
+ else:
+ item = server.views.get_by_id(args.resource_id)
+ if not item:
+ print(f"No item found for id {args.resource_id}")
+ exit(1)
+
+ print(f"Item found: {item.name}")
# We have a number of different types and functions for each different export type.
# We encode that information above in the const=(...) parameter to the add_argument function to make
# the code automatically adapt for the type of export the user is doing.
# We unroll that information into methods we can call, or objects we can create by using getattr()
(populate_func_name, option_factory_name, member_name, extension) = args.type
populate = getattr(server.views, populate_func_name)
+ if args.workbook:
+ populate = getattr(server.workbooks, populate_func_name)
+ elif args.custom_view:
+ populate = getattr(server.custom_views, populate_func_name)
+
option_factory = getattr(TSC, option_factory_name)
+ options: TSC.PDFRequestOptions = option_factory()
if args.filter:
- options = option_factory().vf(*args.filter.split(':'))
- else:
- options = None
+ options = options.vf(*args.filter.split(":"))
+
+ if args.language:
+ options.language = args.language
+
if args.file:
filename = args.file
else:
- filename = 'out.{}'.format(extension)
+ filename = f"out-{options.language}.{extension}"
- populate(view, options)
- with file(filename, 'wb') as f:
- if member_name == 'csv':
- f.writelines(getattr(view, member_name))
+ populate(item, options)
+ with open(filename, "wb") as f:
+ if member_name == "csv":
+ f.writelines(getattr(item, member_name))
else:
- f.write(getattr(view, member_name))
+ f.write(getattr(item, 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
deleted file mode 100644
index 2be476130..000000000
--- a/samples/export_wb.py
+++ /dev/null
@@ -1,92 +0,0 @@
-####
-# This sample uses the PyPDF2 library for combining pdfs together to get the full pdf for all the views in a
-# workbook.
-#
-# You will need to do `pip install PyPDF2` to use this sample.
-#
-# To run the script, you must have installed Python 3.6 or later.
-####
-
-
-import argparse
-import logging
-import tempfile
-import shutil
-import functools
-import os.path
-
-import tableauserverclient as TSC
-try:
- import PyPDF2
-except ImportError:
- print('Please `pip install PyPDF2` to use this sample')
- import sys
- sys.exit(1)
-
-
-def get_views_for_workbook(server, workbook_id): # -> Iterable of views
- workbook = server.workbooks.get_by_id(workbook_id)
- server.workbooks.populate_views(workbook)
- return workbook.views
-
-
-def download_pdf(server, tempdir, view): # -> Filename to downloaded pdf
- logging.info("Exporting {}".format(view.id))
- destination_filename = os.path.join(tempdir, view.id)
- server.views.populate_pdf(view)
- with file(destination_filename, 'wb') as f:
- f.write(view.pdf)
-
- return destination_filename
-
-
-def combine_into(dest_pdf, filename): # -> None
- dest_pdf.append(filename)
- return dest_pdf
-
-
-def cleanup(tempdir):
- shutil.rmtree(tempdir)
-
-
-def main():
- parser = argparse.ArgumentParser(description='Export to PDF all of the views in a workbook.')
- # Common options; please keep those in sync across all samples
- parser.add_argument('--server', '-s', required=True, help='server address')
- parser.add_argument('--site', '-S', help='site name')
- parser.add_argument('--token-name', '-p', required=True,
- help='name of the personal access token used to sign into the server')
- parser.add_argument('--token-value', '-v', required=True,
- help='value of the personal access token used to sign into the server')
- parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error',
- help='desired logging level (set to error by default)')
- # Options specific to this sample
- parser.add_argument('--file', '-f', default='out.pdf', help='filename to store the exported data')
- parser.add_argument('resource_id', help='LUID for the workbook')
-
- args = parser.parse_args()
-
- # Set logging level based on user input, or error by default
- logging_level = getattr(logging, args.logging_level.upper())
- logging.basicConfig(level=logging_level)
-
- tempdir = tempfile.mkdtemp('tsc')
- logging.debug("Saving to tempdir: %s", tempdir)
-
- try:
- tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site)
- server = TSC.Server(args.server, use_server_version=True)
- with server.auth.sign_in(tableau_auth):
- get_list = functools.partial(get_views_for_workbook, server)
- download = functools.partial(download_pdf, server, tempdir)
-
- downloaded = (download(x) for x in get_list(args.resource_id))
- output = reduce(combine_into, downloaded, PyPDF2.PdfFileMerger())
- with file(args.file, 'wb') as f:
- output.write(f)
- finally:
- cleanup(tempdir)
-
-
-if __name__ == '__main__':
- main()
diff --git a/samples/extracts.py b/samples/extracts.py
new file mode 100644
index 000000000..d9289452a
--- /dev/null
+++ b/samples/extracts.py
@@ -0,0 +1,90 @@
+####
+# This script demonstrates how to use the Tableau Server Client to interact with extracts.
+# It explores the different functions that the REST API supports on extracts.
+#####
+
+import argparse
+import logging
+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", help="server address")
+ parser.add_argument("--site", help="site name")
+ parser.add_argument("--token-name", "-tn", help="name of the personal access token used to sign into the server")
+ parser.add_argument("--token-value", "-tv", help="value of the personal access token used to sign into the server")
+ parser.add_argument(
+ "--logging-level",
+ "-l",
+ choices=["debug", "info", "error"],
+ default="error",
+ help="desired logging level (set to error by default)",
+ )
+ # Options specific to this sample
+ parser.add_argument("--create", action="store_true")
+ parser.add_argument("--delete", action="store_true")
+ parser.add_argument("--refresh", action="store_true")
+ parser.add_argument("--workbook", required=False)
+ parser.add_argument("--datasource", required=False)
+ args = parser.parse_args()
+
+ # Set logging level based on user input, or error by default
+ 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):
+ wb = None
+ ds = None
+ if args.workbook:
+ wb = server.workbooks.get_by_id(args.workbook)
+ if wb is None:
+ raise ValueError(f"Workbook not found for id {args.workbook}")
+ elif args.datasource:
+ ds = server.datasources.get_by_id(args.datasource)
+ if ds is None:
+ raise ValueError(f"Datasource not found for id {args.datasource}")
+ else:
+ # Gets all workbook items
+ all_workbooks, pagination_item = server.workbooks.get()
+ print(f"\nThere are {pagination_item.total_available} workbooks on site: ")
+ print([workbook.name for workbook in all_workbooks])
+
+ if all_workbooks:
+ # Pick one workbook from the list
+ wb = all_workbooks[3]
+
+ if args.create:
+ print("create extract on wb ", wb.name)
+ extract_job = server.workbooks.create_extract(wb, includeAll=True)
+ print(extract_job)
+
+ if args.refresh:
+ extract_job = None
+ if ds is not None:
+ print(f"refresh extract on datasource {ds.name}")
+ extract_job = server.datasources.refresh(ds, includeAll=True, incremental=True)
+ elif wb is not None:
+ print(f"refresh extract on workbook {wb.name}")
+ extract_job = server.workbooks.refresh(wb)
+ else:
+ print("no content item selected to refresh")
+
+ print(extract_job)
+
+ if args.delete:
+ print("delete extract on wb ", wb.name)
+ jj = server.workbooks.delete_extract(wb)
+ print(jj)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/samples/filter_sort_groups.py b/samples/filter_sort_groups.py
index 24dee791d..1694bf0f5 100644
--- a/samples/filter_sort_groups.py
+++ b/samples/filter_sort_groups.py
@@ -2,37 +2,41 @@
# This script demonstrates how to filter and sort groups using the Tableau
# Server Client.
#
-# To run the script, you must have installed Python 3.6 or later.
+# To run the script, you must have installed Python 3.7 or later.
####
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", help="server address")
+ parser.add_argument("--site", "-S", help="site name")
+ parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server")
+ parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
+ parser.add_argument(
+ "--logging-level",
+ "-l",
+ choices=["debug", "info", "error"],
+ default="error",
+ help="desired logging level (set to error by default)",
+ )
# Options specific to this sample
# This sample has no additional options, yet. If you add some, please add them here
@@ -43,47 +47,70 @@ def main():
logging.basicConfig(level=logging_level)
tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site)
- server = TSC.Server(args.server, use_server_version=True)
+ server = TSC.Server(args.server, use_server_version=True, http_options={"verify": False})
with server.auth.sign_in(tableau_auth):
-
- group_name = 'SALES NORTHWEST'
+ 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'
+ # we no longer need to encode the space
options = TSC.RequestOptions()
- options.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name,
- TSC.RequestOptions.Operator.Equals,
- filter_group_name))
+ options.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, group_name))
filtered_groups, _ = server.groups.get(req_options=options)
# Result can either be a matching group or an empty list
if filtered_groups:
- group_name = filtered_groups.pop().name
- print(group_name)
+ group = filtered_groups.pop()
+ print(group)
else:
- error = "No project named '{}' found".format(filter_group_name)
+ error = f"No group named '{group_name}' found"
print(error)
+ print("---")
+
+ # Or, try the above with the django style filtering
+ try:
+ group = server.groups.filter(name=group_name)[0]
+ print(group)
+ except IndexError:
+ print(f"No group named '{group_name}' found")
+
+ print("====")
+
options = TSC.RequestOptions()
- options.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name,
- TSC.RequestOptions.Operator.In,
- ['SALES+NORTHWEST', 'SALES+ROMANIA', 'this_group']))
+ 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)
+ print("----")
+ # or, try the above with the django style filtering.
+ all_g = server.groups.all()
+ print(f"Searching locally among {all_g.total_available} groups")
+ for a in all_g:
+ print(a)
+ groups = [urllib.parse.quote_plus(group) for group in ["SALES NORTHWEST", "SALES ROMANIA", "this_group"]]
+ print(groups)
+
+ for group in server.groups.filter(name__in=groups).order_by("-name"):
+ print(group.name)
+
+ print("done")
+
-if __name__ == '__main__':
+if __name__ == "__main__":
main()
diff --git a/samples/filter_sort_projects.py b/samples/filter_sort_projects.py
index 23b350fa6..6c3a85dcd 100644
--- a/samples/filter_sort_projects.py
+++ b/samples/filter_sort_projects.py
@@ -2,38 +2,44 @@
# This script demonstrates how to use the Tableau Server Client
# to filter and sort on the name of the projects present on site.
#
-# To run the script, you must have installed Python 3.6 or later.
+# To run the script, you must have installed Python 3.7 or later.
####
import argparse
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):
-
- new_project = TSC.ProjectItem(name=name, content_permissions=content_permissions,
- description=description)
+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)
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", help="server address")
+ parser.add_argument("--site", "-S", help="site name")
+ parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server")
+ parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
+ parser.add_argument(
+ "--logging-level",
+ "-l",
+ choices=["debug", "info", "error"],
+ default="error",
+ help="desired logging level (set to error by default)",
+ )
# Options specific to this sample
# This sample has no additional options, yet. If you add some, please add them here
@@ -49,12 +55,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
@@ -62,29 +68,36 @@ def main():
project_name = filtered_projects.pop().name
print(project_name)
else:
- error = "No project named '{}' found".format(filter_project_name)
+ error = f"No project named '{filter_project_name}' found"
print(error)
- create_example_project(name='Example 1', server=server)
- 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/getting_started/1_hello_server.py b/samples/getting_started/1_hello_server.py
new file mode 100644
index 000000000..5f8cfa238
--- /dev/null
+++ b/samples/getting_started/1_hello_server.py
@@ -0,0 +1,21 @@
+####
+# Getting started Part One of Three
+# This script demonstrates how to use the Tableau Server Client to connect to a server
+# You don't need to have a site or any experience with Tableau to run it
+#
+####
+
+import tableauserverclient as TSC
+
+
+def main():
+ # This is the domain for Tableau's Developer Program
+ server_url = "https://10ax.online.tableau.com"
+ server = TSC.Server(server_url)
+ print(f"Connected to {server.server_info.baseurl}")
+ print(f"Server information: {server.server_info}")
+ print("Sign up for a test site at https://www.tableau.com/developer")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/samples/getting_started/2_hello_site.py b/samples/getting_started/2_hello_site.py
new file mode 100644
index 000000000..8635947a8
--- /dev/null
+++ b/samples/getting_started/2_hello_site.py
@@ -0,0 +1,50 @@
+####
+# Getting started Part Two of Three
+# This script demonstrates how to use the Tableau Server Client to
+# view the content on an existing site on Tableau Server/Online
+# It assumes that you have already got a site and can visit it in a browser
+#
+####
+
+import getpass
+import tableauserverclient as TSC
+
+
+# 0 - launch your Tableau site in a web browser and look at the url to set the values below
+def main():
+ # 1 - replace with your server domain: stop at the slash
+ server_url = "https://10ax.online.tableau.com"
+
+ # 2 - optional - change to false **for testing only** if you get a certificate error
+ use_ssl = True
+
+ server = TSC.Server(server_url, use_server_version=True, http_options={"verify": use_ssl})
+ print(f"Connected to {server.server_info.baseurl}")
+
+ # 3 - replace with your site name exactly as it looks in the url
+ # e.g https://my-server/#/site/this-is-your-site-url-name/not-this-part
+ site_url_name = "" # leave empty if there is no site name in the url (you are on the default site)
+
+ # 4 - replace with your username.
+ # REMEMBER: if you are using Tableau Online, your username is the entire email address
+ username = "your-username-here"
+ password = getpass.getpass("Your password:") # so you don't save it in this file
+ tableau_auth = TSC.TableauAuth(username, password, site_id=site_url_name)
+
+ # OR instead of username+password, uncomment this section to use a Personal Access Token
+ # token_name = "your-token-name"
+ # token_value = "your-token-value-long-random-string"
+ # tableau_auth = TSC.PersonalAccessTokenAuth(token_name, token_value, site_id=site_url_name)
+
+ with server.auth.sign_in(tableau_auth):
+ projects, pagination = server.projects.get()
+ if projects:
+ print(f"{pagination.total_available} projects")
+ project = projects[0]
+ print(project.name)
+
+ print("Done")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/samples/getting_started/3_hello_universe.py b/samples/getting_started/3_hello_universe.py
new file mode 100644
index 000000000..a2c4301d0
--- /dev/null
+++ b/samples/getting_started/3_hello_universe.py
@@ -0,0 +1,92 @@
+####
+# Getting Started Part Three of Three
+# This script demonstrates all the different types of 'content' a server contains
+#
+# To make it easy to run, it doesn't take any arguments - you need to edit the code with your info
+####
+
+import getpass
+import tableauserverclient as TSC
+
+
+def main():
+ # 1 - replace with your server url
+ server_url = "https://10ax.online.tableau.com"
+
+ # 2 - change to false **for testing only** if you get a certificate error
+ use_ssl = True
+ server = TSC.Server(server_url, use_server_version=True, http_options={"verify": use_ssl})
+
+ print(f"Connected to {server.server_info.baseurl}")
+
+ # 3 - replace with your site name exactly as it looks in a url
+ # e.g https://my-server/#/this-is-your-site-url-name/
+ site_url_name = "" # leave empty if there is no site name in the url (you are on the default site)
+
+ # 4
+ username = "your-username-here"
+ password = getpass.getpass("Your password:") # so you don't save it in this file
+ tableau_auth = TSC.TableauAuth(username, password, site_id=site_url_name)
+
+ # OR instead of username+password, use a Personal Access Token (PAT) (required by Tableau Cloud)
+ # token_name = "your-token-name"
+ # token_value = "your-token-value-long-random-string"
+ # tableau_auth = TSC.PersonalAccessTokenAuth(token_name, token_value, site_id=site_url_name)
+
+ with server.auth.sign_in(tableau_auth):
+ projects, pagination = server.projects.get()
+ if projects:
+ print(f"{pagination.total_available} projects")
+ for project in projects:
+ print(project.name)
+
+ workbooks, pagination = server.datasources.get()
+ if workbooks:
+ print(f"{pagination.total_available} workbooks")
+ print(workbooks[0])
+
+ views, pagination = server.views.get()
+ if views:
+ print(f"{pagination.total_available} views")
+ print(views[0])
+
+ datasources, pagination = server.datasources.get()
+ if datasources:
+ print(f"{pagination.total_available} datasources")
+ print(datasources[0])
+
+ # I think all these other content types can go to a hello_universe script
+ # data alert, dqw, flow, ... do any of these require any add-ons?
+ jobs, pagination = server.jobs.get()
+ if jobs:
+ print(f"{pagination.total_available} jobs")
+ print(jobs[0])
+
+ schedules, pagination = server.schedules.get()
+ if schedules:
+ print(f"{pagination.total_available} schedules")
+ print(schedules[0])
+
+ tasks, pagination = server.tasks.get()
+ if tasks:
+ print(f"{pagination.total_available} tasks")
+ print(tasks[0])
+
+ webhooks, pagination = server.webhooks.get()
+ if webhooks:
+ print(f"{pagination.total_available} webhooks")
+ print(webhooks[0])
+
+ users, pagination = server.users.get()
+ if users:
+ print(f"{pagination.total_available} users")
+ print(users[0])
+
+ groups, pagination = server.groups.get()
+ if groups:
+ print(f"{pagination.total_available} groups")
+ print(groups[0])
+
+
+if __name__ == "__main__":
+ main()
diff --git a/samples/initialize_server.py b/samples/initialize_server.py
index a7dd552e1..cdfaf27a8 100644
--- a/samples/initialize_server.py
+++ b/samples/initialize_server.py
@@ -11,20 +11,23 @@
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", help="server address")
+ parser.add_argument("--site", "-S", help="site name")
+ parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server")
+ parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
+ parser.add_argument(
+ "--logging-level",
+ "-l",
+ choices=["debug", "info", "error"],
+ default="error",
+ help="desired logging level (set to error by default)",
+ )
# Options specific to this sample
- 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", help="folder containing datasources")
+ parser.add_argument("--workbooks-folder", "-wf", help="folder containing workbooks")
+ parser.add_argument("--project", required=False, default="Default", help="project to use")
args = parser.parse_args()
@@ -38,7 +41,6 @@ def main():
tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site)
server = TSC.Server(args.server, use_server_version=True)
with server.auth.sign_in(tableau_auth):
-
################################################################################
# Step 2: Create the site we need only if it doesn't exist
################################################################################
@@ -49,12 +51,15 @@ 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)
+ print(f"Site not found: {args.site_id} Creating it...")
+ 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)
+ print(f"Site {args.site_id} exists. Moving on...")
################################################################################
# Step 3: Sign-in to our target site
@@ -65,18 +70,18 @@ def main():
tableau_auth.site_id = args.site_id
with server_upload.auth.sign_in(tableau_auth):
-
################################################################################
# Step 4: Create the project we need only if it doesn't exist
################################################################################
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)
# Create our project if it doesn't exist
if project is None:
- print("Project not found: {0} Creating it...").format(args.project)
+ print(f"Project not found: {args.project} Creating it...")
new_project = TSC.ProjectItem(name=args.project)
project = server_upload.projects.create(new_project)
@@ -90,22 +95,22 @@ 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)
new_ds = server_object.datasources.publish(new_ds, fname, server_object.PublishMode.Overwrite)
- print("Datasource published. ID: {0}".format(new_ds.id))
+ print(f"Datasource published. ID: {new_ds.id}")
def publish_workbooks_to_site(server_object, project, folder):
- path = folder + '/*.twb*'
+ path = folder + "/*.twb*"
for fname in glob.glob(path):
new_workbook = TSC.WorkbookItem(project.id)
new_workbook.show_tabs = True
new_workbook = server_object.workbooks.publish(new_workbook, fname, server_object.PublishMode.Overwrite)
- print("Workbook published. ID: {0}".format(new_workbook.id))
+ print(f"Workbook published. ID: {new_workbook.id}")
if __name__ == "__main__":
diff --git a/samples/kill_all_jobs.py b/samples/kill_all_jobs.py
index 196da4b01..bfebb49b8 100644
--- a/samples/kill_all_jobs.py
+++ b/samples/kill_all_jobs.py
@@ -1,7 +1,7 @@
####
# This script demonstrates how to kill all of the running jobs
#
-# To run the script, you must have installed Python 3.6 or later.
+# To run the script, you must have installed Python 3.7 or later.
####
import argparse
@@ -11,16 +11,19 @@
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", help="server address")
+ parser.add_argument("--site", "-S", help="site name")
+ parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server")
+ parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
+ parser.add_argument(
+ "--logging-level",
+ "-l",
+ choices=["debug", "info", "error"],
+ default="error",
+ help="desired logging level (set to error by default)",
+ )
# Options specific to this sample
# This sample has no additional options, yet. If you add some, please add them here
@@ -40,5 +43,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..2675a2954 100644
--- a/samples/list.py
+++ b/samples/list.py
@@ -1,7 +1,7 @@
####
# This script demonstrates how to list all of the workbooks or datasources
#
-# To run the script, you must have installed Python 3.6 or later.
+# To run the script, you must have installed Python 3.7 or later.
####
import argparse
@@ -13,18 +13,21 @@
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", help="server address")
+ parser.add_argument("--site", "-S", help="site name")
+ parser.add_argument("--token-name", "-n", help="name of the personal access token used to sign into the server")
+ parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
+ parser.add_argument(
+ "--logging-level",
+ "-l",
+ 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 +40,30 @@ 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)
+ if endpoint is None:
+ print("Resource type not found.")
+ sys.exit(1)
- for resource in TSC.Pager(endpoint.get):
- print(resource.id, resource.name)
+ 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
+ # endpoint.populate_connections(resource)
+ print(resource.name[:18], " ") # , resource._connections())
+ if count > 100:
+ break
+ print(f"Total: {count}")
-if __name__ == '__main__':
+
+if __name__ == "__main__":
main()
diff --git a/samples/login.py b/samples/login.py
index c8af97505..bc99385b3 100644
--- a/samples/login.py
+++ b/samples/login.py
@@ -1,59 +1,94 @@
####
# This script demonstrates how to log in to Tableau Server Client.
#
-# To run the script, you must have installed Python 3.6 or later.
+# To run the script, you must have installed Python 3.7 or later.
####
import argparse
import getpass
import logging
+import os
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')
+def get_env(key):
+ if key in os.environ:
+ return os.environ[key]
+ return None
+
+# If a sample has additional arguments, then it should copy this code and insert them after the call to
+# 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()
+ if not args.server:
+ args.server = get_env("SERVER")
+ if not args.site:
+ args.site = get_env("SITE")
+ if not args.token_name:
+ args.token_name = get_env("TOKEN_NAME")
+ if not args.token_value:
+ args.token_value = get_env("TOKEN_VALUE")
+ args.logging_level = "debug"
+
+ 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", help="server address")
+ parser.add_argument("--site", "-t", help="site name")
+ auth = parser.add_mutually_exclusive_group(required=False)
+ auth.add_argument("--token-name", "-tn", help="name of the personal access token used to sign into the server")
+ auth.add_argument("--username", "-u", help="username to sign into the server")
- # Set logging level based on user input, or error by default.
- logging_level = getattr(logging, args.logging_level.upper())
- logging.basicConfig(level=logging_level)
+ 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)",
+ )
- # Make sure we use an updated version of the rest apis.
- server = TSC.Server(args.server, use_server_version=True)
+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(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\nUsername: {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(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\nToken name: {args.token_name}")
+
+ if not tableau_auth:
+ raise TabError("Did not create authentication object. Check arguments.")
+
+ # 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)
+ server.version = "3.19"
- 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..7524453c2 100644
--- a/samples/metadata_query.py
+++ b/samples/metadata_query.py
@@ -1,7 +1,7 @@
####
# This script demonstrates how to use the metadata API to query information on a published data source
#
-# To run the script, you must have installed Python 3.6 or later.
+# To run the script, you must have installed Python 3.7 or later.
####
import argparse
@@ -12,19 +12,25 @@
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", help="server address")
+ parser.add_argument("--site", "-S", help="site name")
+ parser.add_argument("--token-name", "-n", help="name of the personal access token used to sign into the server")
+ parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
+ parser.add_argument(
+ "--logging-level",
+ "-l",
+ 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 +43,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 +55,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..392dc0ff8 100644
--- a/samples/move_workbook_projects.py
+++ b/samples/move_workbook_projects.py
@@ -4,30 +4,33 @@
# a workbook that matches a given name and update it to be in
# the desired project.
#
-# To run the script, you must have installed Python 3.6 or later.
+# To run the script, you must have installed Python 3.7 or later.
####
import argparse
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", help="server address")
+ parser.add_argument("--site", "-S", help="site name")
+ parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server")
+ parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
+ parser.add_argument(
+ "--logging-level",
+ "-l",
+ choices=["debug", "info", "error"],
+ default="error",
+ help="desired logging level (set to error by default)",
+ )
# Options specific to this sample
- parser.add_argument('--workbook-name', '-w', required=True, help='name of workbook to move')
- parser.add_argument('--destination-project', '-d', required=True, help='name of project to move workbook into')
+ parser.add_argument("--workbook-name", "-w", help="name of workbook to move")
+ parser.add_argument("--destination-project", "-d", help="name of project to move workbook into")
args = parser.parse_args()
@@ -39,30 +42,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..e82c75cf9 100644
--- a/samples/move_workbook_sites.py
+++ b/samples/move_workbook_sites.py
@@ -4,7 +4,7 @@
# a workbook that matches a given name, download the workbook,
# and then publish it to the destination site.
#
-# To run the script, you must have installed Python 3.6 or later.
+# To run the script, you must have installed Python 3.7 or later.
####
import argparse
@@ -16,23 +16,26 @@
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", help="server address")
+ parser.add_argument("--site", "-S", help="site name")
+ parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server")
+ parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
+ parser.add_argument(
+ "--logging-level",
+ "-l",
+ choices=["debug", "info", "error"],
+ default="error",
+ help="desired logging level (set to error by default)",
+ )
# Options specific to this sample
- parser.add_argument('--workbook-name', '-w', required=True, help='name of workbook to move')
- parser.add_argument('--destination-site', '-d', required=True, help='name of site to move workbook into')
-
+ parser.add_argument("--workbook-name", "-w", help="name of workbook to move")
+ parser.add_argument("--destination-site", "-d", help="name of site to move workbook into")
args = parser.parse_args()
@@ -49,13 +52,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(f"No workbook named {args.workbook_name} found.")
else:
tmpdir = tempfile.mkdtemp()
try:
@@ -63,10 +67,11 @@ 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)
+ error = f"No site named {args.destination_site} found."
raise LookupError(error)
tableau_auth.site_id = args.destination_site
@@ -74,13 +79,13 @@ def main():
# Signing into another site requires another server object
# because of the different auth token and site ID.
with dest_server.auth.sign_in(tableau_auth):
-
# Step 5: Create a new workbook item and publish workbook. Note that
# an empty project_id will publish to the 'Default' project.
new_workbook = TSC.WorkbookItem(name=args.workbook_name, project_id="")
- 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))
+ new_workbook = dest_server.workbooks.publish(
+ new_workbook, workbook_path, mode=TSC.Server.PublishMode.Overwrite
+ )
+ print(f"Successfully moved {new_workbook.name} ({new_workbook.id})")
# Step 6: Delete workbook from source site and delete temp directory
source_server.workbooks.delete(all_workbooks[0].id)
@@ -89,5 +94,5 @@ def main():
shutil.rmtree(tmpdir)
-if __name__ == '__main__':
+if __name__ == "__main__":
main()
diff --git a/samples/online_users.csv b/samples/online_users.csv
new file mode 100644
index 000000000..bf4843679
--- /dev/null
+++ b/samples/online_users.csv
@@ -0,0 +1,2 @@
+ayoung@tableau.com, , , "Creator", None, Yes
+ahsiao@tableau.com, , , "Explorer", None, No
diff --git a/samples/pagination_sample.py b/samples/pagination_sample.py
index 2ebd011dc..a68eed4b3 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
####
@@ -17,19 +18,21 @@
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", help="server address")
+ parser.add_argument("--site", "-S", help="site name")
+ parser.add_argument("--token-name", "-n", help="name of the personal access token used to sign into the server")
+ parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
+ parser.add_argument(
+ "--logging-level",
+ "-l",
+ 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 +44,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(f"Total: {count}\n")
+
+ 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(f"Truncated Total: {count}\n")
+
+ 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(f"Filtered Total: {count}\n")
- # 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(f"QuerySet Total: {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..c674e6882 100644
--- a/samples/publish_datasource.py
+++ b/samples/publish_datasource.py
@@ -15,44 +15,68 @@
# more information on personal access tokens, refer to the documentations:
# (https://help.tableau.com/current/server/en-us/security_personal_access_tokens.htm)
#
-# To run the script, you must have installed Python 3.6 or later.
+# To run the script, you must have installed Python 3.7 or later.
####
import argparse
import logging
+import os
import tableauserverclient as TSC
+import tableauserverclient.datetime_helpers
+
+
+def get_env(key):
+ if key in os.environ:
+ return os.environ[key]
+ return None
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", help="server address")
+ parser.add_argument("--site", "-S", help="site name")
+ parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server")
+ parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
+ parser.add_argument(
+ "--logging-level",
+ "-l",
+ choices=["debug", "info", "error"],
+ default="error",
+ help="desired logging level (set to error by default)",
+ )
# Options specific to this sample
- 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", 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()
+ if not args.server:
+ args.server = get_env("SERVER")
+ if not args.site:
+ args.site = get_env("SITE")
+ if not args.token_name:
+ args.token_name = get_env("TOKEN_NAME")
+ if not args.token_value:
+ args.token_value = get_env("TOKEN_VALUE")
+ args.logging = "debug"
+ args.file = "C:/dev/tab-samples/5M.tdsx"
+ args.async_ = True
# Ensure that both the connection username and password are provided, or none at all
if (args.conn_username and not args.conn_password) or (not args.conn_username and args.conn_password):
parser.error("Both the connection username and password must be provided")
# Set logging level based on user input, or error by default
- logging_level = getattr(logging, args.logging_level.upper())
- logging.basicConfig(level=logging_level)
+
+ _logger = logging.getLogger(__name__)
+ _logger.setLevel(logging.DEBUG)
+ _logger.addHandler(logging.StreamHandler())
# Sign in to server
tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site)
@@ -64,9 +88,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,24 +102,35 @@ 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
# Publish datasource
if args.async_:
+ print("Publish as a job")
# 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)
- print("Datasource published asynchronously. Job ID: {0}".format(new_job.id))
+ new_job = server.datasources.publish(
+ new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds, as_job=True
+ )
+ print(f"Datasource published asynchronously. Job ID: {new_job.id}")
else:
# Normal publishing, returns a datasource_item
- 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))
+ new_datasource = server.datasources.publish(
+ new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds
+ )
+ print(
+ (
+ "{}Datasource published. Datasource ID: {}".format(
+ new_datasource.id, tableauserverclient.datetime_helpers.timestamp()
+ )
+ )
+ )
+ print("\t\tClosing connection")
-if __name__ == '__main__':
+if __name__ == "__main__":
main()
diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py
index fcfcddc15..077ddaddd 100644
--- a/samples/publish_workbook.py
+++ b/samples/publish_workbook.py
@@ -11,7 +11,7 @@
# For more information, refer to the documentations on 'Publish Workbook'
# (https://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm)
#
-# To run the script, you must have installed Python 3.6 or later.
+# To run the script, you must have installed Python 3.7 or later.
####
import argparse
@@ -22,22 +22,30 @@
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", help="server address")
+ parser.add_argument("--site", "-S", help="site name")
+ parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server")
+ parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
+ parser.add_argument(
+ "--logging-level",
+ "-l",
+ choices=["debug", "info", "error"],
+ default="error",
+ help="desired logging level (set to error by default)",
+ )
# Options specific to this sample
- 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')
+ group = parser.add_mutually_exclusive_group(required=False)
+ group.add_argument("--thumbnails-user-id", "-u", help="User ID to use for thumbnails")
+ group.add_argument("--thumbnails-group-id", "-g", help="Group ID to use for thumbnails")
+ parser.add_argument("--workbook-name", "-n", help="Name with which to publish the workbook")
+ parser.add_argument("--file", "-f", help="local filepath of the workbook to publish")
+ parser.add_argument("--as-job", "-a", help="Publishing asynchronously", action="store_true")
+ parser.add_argument("--skip-connection-check", "-c", help="Skip live connection check", action="store_true")
+ parser.add_argument("--project", help="Project within which to publish the workbook")
+ parser.add_argument("--show-tabs", help="Publish workbooks with tabs displayed", action="store_true")
args = parser.parse_args()
@@ -47,12 +55,22 @@ def main():
# Step 1: Sign in to server.
tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site)
- server = TSC.Server(args.server, use_server_version=True)
+ server = TSC.Server(args.server, use_server_version=True, http_options={"verify": False})
with server.auth.sign_in(tableau_auth):
-
- # Step 2: Get all the projects on server, then look for the default one.
- all_projects, pagination_item = server.projects.get()
- default_project = next((project for project in all_projects if project.is_default()), None)
+ # Step2: Retrieve the project id, if a project name was passed
+ if args.project is not None:
+ req_options = TSC.RequestOptions()
+ req_options.filter.add(
+ TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, args.project)
+ )
+ projects = list(TSC.Pager(server.projects, req_options))
+ if len(projects) > 1:
+ raise ValueError("The project name is not unique")
+ project_id = projects[0].id
+ else:
+ # Get all the projects on server, then look for the default one.
+ all_projects, pagination_item = server.projects.get()
+ project_id = next((project for project in all_projects if project.is_default()), None).id
connection1 = ConnectionItem()
connection1.server_address = "mssql.test.com"
@@ -67,24 +85,40 @@ def main():
all_connections.append(connection1)
all_connections.append(connection2)
- # Step 3: If default project is found, form a new workbook item and publish.
+ # Step 3: Form a new workbook item and publish.
overwrite_true = TSC.Server.PublishMode.Overwrite
- if default_project is not None:
- new_workbook = TSC.WorkbookItem(default_project.id)
+ if project_id is not None:
+ new_workbook = TSC.WorkbookItem(
+ project_id=project_id,
+ name=args.workbook_name,
+ show_tabs=args.show_tabs,
+ thumbnails_user_id=args.thumbnails_user_id,
+ thumbnails_group_id=args.thumbnails_group_id,
+ )
if args.as_job:
- new_job = server.workbooks.publish(new_workbook, 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))
+ new_job = server.workbooks.publish(
+ new_workbook,
+ args.file,
+ overwrite_true,
+ connections=all_connections,
+ as_job=args.as_job,
+ skip_connection_check=args.skip_connection_check,
+ )
+ print(f"Workbook published. JOB ID: {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)
- print("Workbook published. ID: {0}".format(new_workbook.id))
+ new_workbook = server.workbooks.publish(
+ new_workbook,
+ args.file,
+ overwrite_true,
+ connections=all_connections,
+ as_job=args.as_job,
+ skip_connection_check=args.skip_connection_check,
+ )
+ print(f"Workbook published. ID: {new_workbook.id}")
else:
- error = "The default project could not be found."
+ error = "The destination project could not be found."
raise LookupError(error)
-if __name__ == '__main__':
+if __name__ == "__main__":
main()
diff --git a/samples/query_permissions.py b/samples/query_permissions.py
index 0909f915d..3309acd90 100644
--- a/samples/query_permissions.py
+++ b/samples/query_permissions.py
@@ -1,6 +1,6 @@
####
# This script demonstrates how to query for permissions using TSC
-# To run the script, you must have installed Python 3.6 or later.
+# To run the script, you must have installed Python 3.7 or later.
#
# Example usage: 'python query_permissions.py -s https://10ax.online.tableau.com --site
# devSite123 -u tabby@tableau.com workbook b4065286-80f0-11ea-af1b-cb7191f48e45'
@@ -13,19 +13,22 @@
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", help="server address")
+ parser.add_argument("--site", "-S", help="site name")
+ parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server")
+ parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
+ parser.add_argument(
+ "--logging-level",
+ "-l",
+ choices=["debug", "info", "error"],
+ default="error",
+ help="desired logging level (set to error by default)",
+ )
# Options specific to this sample
- 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()
@@ -37,14 +40,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):
-
# 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,17 +57,16 @@ def main():
permissions = resource.permissions
# Print result
- print("\n{0} permission rule(s) found for {1} {2}."
- .format(len(permissions), args.resource_type, args.resource_id))
+ print(f"\n{len(permissions)} permission rule(s) found for {args.resource_type} {args.resource_id}.")
for permission in permissions:
grantee = permission.grantee
capabilities = permission.capabilities
- print("\nCapabilities for {0} {1}:".format(grantee.tag_name, grantee.id))
+ print(f"\nCapabilities for {grantee.tag_name} {grantee.id}:")
for capability in capabilities:
- print("\t{0} - {1}".format(capability, capabilities[capability]))
+ print(f"\t{capability} - {capabilities[capability]}")
-if __name__ == '__main__':
+if __name__ == "__main__":
main()
diff --git a/samples/refresh.py b/samples/refresh.py
index 3eed5b4be..99242fcdb 100644
--- a/samples/refresh.py
+++ b/samples/refresh.py
@@ -1,7 +1,7 @@
####
# This script demonstrates how to use trigger a refresh on a datasource or workbook
#
-# To run the script, you must have installed Python 3.6 or later.
+# To run the script, you must have installed Python 3.7 or later.
####
import argparse
@@ -11,19 +11,24 @@
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", help="server address")
+ parser.add_argument("--site", "-S", help="site name")
+ parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server")
+ parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
+ parser.add_argument(
+ "--logging-level",
+ "-l",
+ choices=["debug", "info", "error"],
+ default="error",
+ help="desired logging level (set to error by default)",
+ )
# Options specific to this sample
- 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")
+ parser.add_argument("--incremental")
+ parser.add_argument("--synchronous")
args = parser.parse_args()
@@ -31,28 +36,43 @@ def main():
logging_level = getattr(logging, args.logging_level.upper())
logging.basicConfig(level=logging_level)
+ refresh_type = "FullRefresh"
+ incremental = False
+ if args.incremental:
+ refresh_type = "Incremental"
+ incremental = True
+
tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site)
- server = TSC.Server(args.server, use_server_version=True)
+ server = TSC.Server(args.server, use_server_version=True, http_options={"verify": False})
with server.auth.sign_in(tableau_auth):
if args.resource_type == "workbook":
# Get the workbook by its Id to make sure it exists
resource = server.workbooks.get_by_id(args.resource_id)
+ print(resource)
# trigger the refresh, you'll get a job id back which can be used to poll for when the refresh is done
- job = server.workbooks.refresh(args.resource_id)
+ job = server.workbooks.refresh(args.resource_id, incremental=incremental)
else:
# Get the datasource by its Id to make sure it exists
resource = server.datasources.get_by_id(args.resource_id)
+ print(resource)
+
+ # server.datasources.create_extract(resource)
# trigger the refresh, you'll get a job id back which can be used to poll for when the refresh is done
- job = server.datasources.refresh(resource)
-
- print(f"Update job posted (ID: {job.id})")
- print("Waiting for job...")
- # `wait_for_job` will throw if the job isn't executed successfully
- job = server.jobs.wait_for_job(job)
- print("Job finished succesfully")
+ job = server.datasources.refresh(resource, incremental=incremental) # by default runs as a sync task,
+
+ print(f"{refresh_type} job posted (ID: {job.id})")
+ if args.synchronous:
+ # equivalent to tabcmd --synchnronous: wait for the job to complete
+ try:
+ # `wait_for_job` will throw if the job isn't executed successfully
+ print("Waiting for job...")
+ server.jobs.wait_for_job(job)
+ print("Job finished succesfully")
+ except Exception as e:
+ print(f"Job failed! {e}")
-if __name__ == '__main__':
+if __name__ == "__main__":
main()
diff --git a/samples/refresh_tasks.py b/samples/refresh_tasks.py
index bf69d064a..c95000898 100644
--- a/samples/refresh_tasks.py
+++ b/samples/refresh_tasks.py
@@ -2,7 +2,7 @@
# This script demonstrates how to use the Tableau Server Client
# to query extract refresh tasks and run them as needed.
#
-# To run the script, you must have installed Python 3.6 or later.
+# To run the script, you must have installed Python 3.7 or later.
####
import argparse
@@ -19,37 +19,40 @@ def handle_run(server, args):
def handle_list(server, _):
tasks, pagination = server.tasks.get()
for task in tasks:
- print("{}".format(task))
+ print(f"{task}")
def handle_info(server, args):
task = server.tasks.get_by_id(args.id)
- print("{}".format(task))
+ print(f"{task}")
def main():
- 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", help="server address")
+ parser.add_argument("--site", "-S", help="site name")
+ parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server")
+ parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
+ parser.add_argument(
+ "--logging-level",
+ "-l",
+ choices=["debug", "info", "error"],
+ default="error",
+ help="desired logging level (set to error by default)",
+ )
# Options specific to this sample
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 +68,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..153bb0ee5 100644
--- a/samples/set_refresh_schedule.py
+++ b/samples/set_refresh_schedule.py
@@ -2,7 +2,7 @@
# This script demonstrates how to set the refresh schedule for
# a workbook or datasource.
#
-# To run the script, you must have installed Python 3.6 or later.
+# To run the script, you must have installed Python 3.7 or later.
####
@@ -13,28 +13,32 @@
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", help="server address")
+ parser.add_argument("--site", "-S", help="site name")
+ parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server")
+ parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
+ parser.add_argument(
+ "--logging-level",
+ "-l",
+ choices=["debug", "info", "error"],
+ default="error",
+ help="desired logging level (set to error by default)",
+ )
# Options specific to this sample
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)
def make_filter(**kwargs):
options = TSC.RequestOptions()
- for item, value in kwargs.items():
+ for item, value in list(kwargs.items()):
name = getattr(TSC.RequestOptions.Field, item)
options.filter.add(TSC.Filter(name, TSC.RequestOptions.Operator.Equals, value))
return options
@@ -54,6 +58,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 +86,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 +100,7 @@ def run(args):
def main():
import sys
+
args = usage(sys.argv[1:])
run(args)
diff --git a/samples/smoke_test.py b/samples/smoke_test.py
new file mode 100644
index 000000000..b23eacdb8
--- /dev/null
+++ b/samples/smoke_test.py
@@ -0,0 +1,16 @@
+# This sample verifies that tableau server client is installed
+# and you can run it. It also shows the version of the client.
+
+import logging
+import tableauserverclient as TSC
+
+
+logger = logging.getLogger("Sample")
+logger.setLevel(logging.DEBUG)
+logger.addHandler(logging.StreamHandler())
+
+
+server = TSC.Server("Fake-Server-Url", use_server_version=False)
+print("Client details:")
+logger.info(server.server_address)
+logger.debug(TSC.server.endpoint.Endpoint.set_user_agent({}))
diff --git a/samples/update_connection.py b/samples/update_connection.py
index 0e87217e8..0fe2f342c 100644
--- a/samples/update_connection.py
+++ b/samples/update_connection.py
@@ -1,7 +1,7 @@
####
# This script demonstrates how to update a connections credentials on a server to embed the credentials
#
-# To run the script, you must have installed Python 3.6 or later.
+# To run the script, you must have installed Python 3.7 or later.
####
import argparse
@@ -11,22 +11,25 @@
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", help="server address")
+ parser.add_argument("--site", "-S", help="site name")
+ parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server")
+ parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
+ parser.add_argument(
+ "--logging-level",
+ "-l",
+ choices=["debug", "info", "error"],
+ default="error",
+ help="desired logging level (set to error by default)",
+ )
# Options specific to this sample
- 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 +40,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)
+ connections = list([x for x in resource.connections if x.id == args.connection_id])
+ assert len(connections) == 1
connection = connections[0]
connection.username = args.datasource_username
connection.password = args.datasource_password
@@ -54,5 +54,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..f6bc92022 100644
--- a/samples/update_datasource_data.py
+++ b/samples/update_datasource_data.py
@@ -21,18 +21,23 @@
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", help="server address")
+ parser.add_argument("--site", "-S", help="site name")
+ parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server")
+ parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
+ parser.add_argument(
+ "--logging-level",
+ "-l",
+ choices=["debug", "info", "error"],
+ default="error",
+ help="desired logging level (set to error by default)",
+ )
# Options specific to this sample
- 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 +66,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 +79,5 @@ def main():
print("Job finished succesfully")
-if __name__ == '__main__':
+if __name__ == "__main__":
main()
diff --git a/samples/update_workbook_data_freshness_policy.py b/samples/update_workbook_data_freshness_policy.py
new file mode 100644
index 000000000..c23e3717f
--- /dev/null
+++ b/samples/update_workbook_data_freshness_policy.py
@@ -0,0 +1,218 @@
+####
+# This script demonstrates how to update workbook data freshness policy using the Tableau
+# Server Client.
+#
+# To run the script, you must have installed Python 3.7 or later.
+####
+
+
+import argparse
+import logging
+
+import tableauserverclient as TSC
+from tableauserverclient import IntervalItem
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Creates sample schedules for each type of frequency.")
+ # Common options; please keep those in sync across all samples
+ parser.add_argument("--server", "-s", help="server address")
+ parser.add_argument("--site", "-S", help="site name")
+ parser.add_argument("--token-name", "-p", help="name of the personal access token " "used to sign into the server")
+ parser.add_argument(
+ "--token-value", "-v", help="value of the personal access token " "used to sign into the server"
+ )
+ parser.add_argument(
+ "--logging-level",
+ "-l",
+ choices=["debug", "info", "error"],
+ default="error",
+ help="desired logging level (set to error by default)",
+ )
+ # Options specific to this sample:
+ # This sample has no additional options, yet. If you add some, please add them here
+
+ args = parser.parse_args()
+
+ # Set logging level based on user input, or error by default
+ logging_level = getattr(logging, args.logging_level.upper())
+ logging.basicConfig(level=logging_level)
+
+ tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site)
+ server = TSC.Server(args.server, use_server_version=False)
+ server.add_http_options({"verify": False})
+ server.use_server_version()
+ with server.auth.sign_in(tableau_auth):
+ # Get workbook
+ all_workbooks, pagination_item = server.workbooks.get()
+ print(f"\nThere are {pagination_item.total_available} workbooks on site: ")
+ print([workbook.name for workbook in all_workbooks])
+
+ if all_workbooks:
+ # Pick 1 workbook that has live datasource connection.
+ # Assuming 1st workbook met the criteria for sample purposes
+ # Data Freshness Policy is not available on extract & file-based datasource.
+ sample_workbook = all_workbooks[2]
+
+ # Get more info from the workbook selected
+ # Troubleshoot: if sample_workbook_extended.data_freshness_policy.option returns with AttributeError
+ # it could mean the workbook selected does not have live connection, which means it doesn't have
+ # data freshness policy. Change to another workbook with live datasource connection.
+ sample_workbook_extended = server.workbooks.get_by_id(sample_workbook.id)
+ try:
+ print(
+ "Workbook "
+ + sample_workbook.name
+ + " has data freshness policy option set to: "
+ + sample_workbook_extended.data_freshness_policy.option
+ )
+ except AttributeError as e:
+ print(
+ "Workbook does not have data freshness policy, possibly due to the workbook selected "
+ "does not have live connection. Change to another workbook using live datasource connection."
+ )
+
+ # Update Workbook Data Freshness Policy to "AlwaysLive"
+ sample_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem(
+ TSC.DataFreshnessPolicyItem.Option.AlwaysLive
+ )
+ updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook)
+ print(
+ "Workbook "
+ + updated.name
+ + " updated data freshness policy option to: "
+ + updated.data_freshness_policy.option
+ )
+
+ # Update Workbook Data Freshness Policy to "SiteDefault"
+ sample_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem(
+ TSC.DataFreshnessPolicyItem.Option.SiteDefault
+ )
+ updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook)
+ print(
+ "Workbook "
+ + updated.name
+ + " updated data freshness policy option to: "
+ + updated.data_freshness_policy.option
+ )
+
+ # Update Workbook Data Freshness Policy to "FreshEvery" schedule.
+ # Set the schedule to be fresh every 10 hours
+ # Once the data_freshness_policy is already populated (e.g. due to previous calls),
+ # it is possible to directly change the option & other parameters directly like below
+ sample_workbook.data_freshness_policy.option = TSC.DataFreshnessPolicyItem.Option.FreshEvery
+ fresh_every_ten_hours = TSC.DataFreshnessPolicyItem.FreshEvery(
+ TSC.DataFreshnessPolicyItem.FreshEvery.Frequency.Hours, 10
+ )
+ sample_workbook.data_freshness_policy.fresh_every_schedule = fresh_every_ten_hours
+ updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook)
+ print(
+ "Workbook "
+ + updated.name
+ + " updated data freshness policy option to: "
+ + updated.data_freshness_policy.option
+ + " with frequency of "
+ + str(updated.data_freshness_policy.fresh_every_schedule.value)
+ + " "
+ + updated.data_freshness_policy.fresh_every_schedule.frequency
+ )
+
+ # Update Workbook Data Freshness Policy to "FreshAt" schedule.
+ # Set the schedule to be fresh at 10AM every day
+ sample_workbook.data_freshness_policy.option = TSC.DataFreshnessPolicyItem.Option.FreshAt
+ fresh_at_ten_daily = TSC.DataFreshnessPolicyItem.FreshAt(
+ TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Day, "10:00:00", "America/Los_Angeles"
+ )
+ sample_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_ten_daily
+ updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook)
+ print(
+ "Workbook "
+ + updated.name
+ + " updated data freshness policy option to: "
+ + updated.data_freshness_policy.option
+ + " with frequency of "
+ + str(updated.data_freshness_policy.fresh_at_schedule.time)
+ + " every "
+ + updated.data_freshness_policy.fresh_at_schedule.frequency
+ )
+
+ # Set the schedule to be fresh at 6PM every week on Wednesday and Sunday
+ sample_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem(
+ TSC.DataFreshnessPolicyItem.Option.FreshAt
+ )
+ fresh_at_6pm_wed_sun = TSC.DataFreshnessPolicyItem.FreshAt(
+ TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Week,
+ "18:00:00",
+ "America/Los_Angeles",
+ [IntervalItem.Day.Wednesday, "Sunday"],
+ )
+
+ sample_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_6pm_wed_sun
+ updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook)
+ new_fresh_at_schedule = updated.data_freshness_policy.fresh_at_schedule
+ print(
+ "Workbook "
+ + updated.name
+ + " updated data freshness policy option to: "
+ + updated.data_freshness_policy.option
+ + " with frequency of "
+ + str(new_fresh_at_schedule.time)
+ + " every "
+ + new_fresh_at_schedule.frequency
+ + " on "
+ + new_fresh_at_schedule.interval_item[0]
+ + ","
+ + new_fresh_at_schedule.interval_item[1]
+ )
+
+ # Set the schedule to be fresh at 12AM every last day of the month
+ sample_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem(
+ TSC.DataFreshnessPolicyItem.Option.FreshAt
+ )
+ fresh_at_last_day_of_month = TSC.DataFreshnessPolicyItem.FreshAt(
+ TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Month, "00:00:00", "America/Los_Angeles", ["LastDay"]
+ )
+
+ sample_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_last_day_of_month
+ updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook)
+ new_fresh_at_schedule = updated.data_freshness_policy.fresh_at_schedule
+ print(
+ "Workbook "
+ + updated.name
+ + " updated data freshness policy option to: "
+ + updated.data_freshness_policy.option
+ + " with frequency of "
+ + str(new_fresh_at_schedule.time)
+ + " every "
+ + new_fresh_at_schedule.frequency
+ + " on "
+ + new_fresh_at_schedule.interval_item[0]
+ )
+
+ # Set the schedule to be fresh at 8PM every 1st,13th,20th day of the month
+ fresh_at_dates_of_month = TSC.DataFreshnessPolicyItem.FreshAt(
+ TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Month,
+ "00:00:00",
+ "America/Los_Angeles",
+ ["1", "13", "20"],
+ )
+
+ sample_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_dates_of_month
+ updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook)
+ new_fresh_at_schedule = updated.data_freshness_policy.fresh_at_schedule
+ print(
+ "Workbook "
+ + updated.name
+ + " updated data freshness policy option to: "
+ + updated.data_freshness_policy.option
+ + " with frequency of "
+ + str(new_fresh_at_schedule.time)
+ + " every "
+ + new_fresh_at_schedule.frequency
+ + " on "
+ + str(new_fresh_at_schedule.interval_item)
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 6136b814a..000000000
--- a/setup.cfg
+++ /dev/null
@@ -1,28 +0,0 @@
-[wheel]
-universal = 1
-
-[pycodestyle]
-select =
-max_line_length = 120
-
-[pep8]
-max_line_length = 120
-
-# See the docstring in versioneer.py for instructions. Note that you must
-# re-run 'versioneer.py setup' after changing this section, and commit the
-# resulting files.
-
-[versioneer]
-VCS = git
-style = pep440
-versionfile_source = tableauserverclient/_version.py
-versionfile_build = tableauserverclient/_version.py
-tag_prefix = v
-#parentdir_prefix =
-
-[aliases]
-smoke=pytest
-
-[tool:pytest]
-testpaths = test smoke
-addopts = --junitxml=./test.junit.xml
diff --git a/setup.py b/setup.py
index 8b374f0ce..bdce51f2e 100644
--- a/setup.py
+++ b/setup.py
@@ -1,42 +1,15 @@
-import sys
import versioneer
-
-try:
- from setuptools import setup
-except ImportError:
- from distutils.core import setup
-
-from os import path
-this_directory = path.abspath(path.dirname(__file__))
-with open(path.join(this_directory, 'README.md'), encoding='utf-8') as f:
- long_description = f.read()
-
-# Only install pytest and runner when test command is run
-# This makes work easier for offline installs or low bandwidth machines
-needs_pytest = {'pytest', 'test', 'ptr'}.intersection(sys.argv)
-pytest_runner = ['pytest-runner'] if needs_pytest else []
-test_requirements = ['mock', 'pycodestyle', 'pytest', 'requests-mock>=1.0,<2.0']
+from setuptools import setup
setup(
- name='tableauserverclient',
version=versioneer.get_version(),
cmdclass=versioneer.get_cmdclass(),
- author='Tableau',
- author_email='github@tableau.com',
- url='https://github.com/tableau/server-client-python',
- packages=['tableauserverclient', 'tableauserverclient.models', 'tableauserverclient.server',
- 'tableauserverclient.server.endpoint'],
- license='MIT',
- description='A Python module for working with the Tableau Server REST API.',
- long_description=long_description,
- long_description_content_type='text/markdown',
- test_suite='test',
- setup_requires=pytest_runner,
- install_requires=[
- 'requests>=2.11,<3.0',
+ # not yet sure how to move this to pyproject.toml
+ packages=[
+ "tableauserverclient",
+ "tableauserverclient.helpers",
+ "tableauserverclient.models",
+ "tableauserverclient.server",
+ "tableauserverclient.server.endpoint",
],
- tests_require=test_requirements,
- extras_require={
- 'test': test_requirements
- }
)
diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py
index 2ad65d71e..21e2c4760 100644
--- a/tableauserverclient/__init__.py
+++ b/tableauserverclient/__init__.py
@@ -1,56 +1,145 @@
-from .namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE
-from .models import (
+from tableauserverclient.bin._version import get_versions
+from tableauserverclient.namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE
+from tableauserverclient.models import (
+ BackgroundJobItem,
+ ColumnItem,
ConnectionCredentials,
ConnectionItem,
+ CustomViewItem,
+ DQWItem,
+ DailyInterval,
DataAlertItem,
+ DatabaseItem,
+ DataFreshnessPolicyItem,
DatasourceItem,
- DQWItem,
+ FavoriteItem,
+ FlowItem,
+ FlowRunItem,
+ FileuploadItem,
GroupItem,
+ GroupSetItem,
+ HourlyInterval,
+ IntervalItem,
JobItem,
- BackgroundJobItem,
+ JWTAuth,
+ LinkedTaskItem,
+ LinkedTaskStepItem,
+ LinkedTaskFlowRunItem,
+ LocationItem,
+ MetricItem,
+ MonthlyInterval,
PaginationItem,
+ Permission,
+ PermissionsRule,
+ PersonalAccessTokenAuth,
ProjectItem,
+ Resource,
+ RevisionItem,
ScheduleItem,
+ SiteAuthConfiguration,
SiteItem,
+ ServerInfoItem,
+ SubscriptionItem,
+ TableauItem,
+ TableItem,
TableauAuth,
- PersonalAccessTokenAuth,
+ Target,
+ TaskItem,
UserItem,
ViewItem,
- WorkbookItem,
- UnpopulatedPropertyError,
- HourlyInterval,
- DailyInterval,
- WeeklyInterval,
- MonthlyInterval,
- IntervalItem,
- TaskItem,
- SubscriptionItem,
- Target,
- PermissionsRule,
- Permission,
- DatabaseItem,
- TableItem,
- ColumnItem,
- FlowItem,
+ VirtualConnectionItem,
WebhookItem,
- PersonalAccessTokenAuth,
- FlowRunItem
+ WeeklyInterval,
+ WorkbookItem,
)
-from .server import (
- RequestOptions,
+
+from tableauserverclient.server import (
CSVRequestOptions,
+ ExcelRequestOptions,
ImageRequestOptions,
PDFRequestOptions,
- Filter,
- Sort,
- Server,
- ServerResponseError,
+ PPTXRequestOptions,
+ RequestOptions,
MissingRequiredFieldError,
+ FailedSignInError,
NotSignedInError,
+ ServerResponseError,
+ Filter,
Pager,
+ Server,
+ Sort,
)
-from ._version import get_versions
-__version__ = get_versions()["version"]
-__VERSION__ = __version__
-del get_versions
+__all__ = [
+ "BackgroundJobItem",
+ "BackgroundJobItem",
+ "ColumnItem",
+ "ConnectionCredentials",
+ "ConnectionItem",
+ "CSVRequestOptions",
+ "CustomViewItem",
+ "DailyInterval",
+ "DataAlertItem",
+ "DatabaseItem",
+ "DataFreshnessPolicyItem",
+ "DatasourceItem",
+ "DEFAULT_NAMESPACE",
+ "DQWItem",
+ "ExcelRequestOptions",
+ "FailedSignInError",
+ "FavoriteItem",
+ "FileuploadItem",
+ "Filter",
+ "FlowItem",
+ "FlowRunItem",
+ "get_versions",
+ "GroupItem",
+ "GroupSetItem",
+ "HourlyInterval",
+ "ImageRequestOptions",
+ "IntervalItem",
+ "JobItem",
+ "JWTAuth",
+ "LinkedTaskFlowRunItem",
+ "LinkedTaskItem",
+ "LinkedTaskStepItem",
+ "LocationItem",
+ "MetricItem",
+ "MissingRequiredFieldError",
+ "MonthlyInterval",
+ "NotSignedInError",
+ "Pager",
+ "PaginationItem",
+ "PDFRequestOptions",
+ "PPTXRequestOptions",
+ "Permission",
+ "PermissionsRule",
+ "PersonalAccessTokenAuth",
+ "ProjectItem",
+ "RequestOptions",
+ "Resource",
+ "RevisionItem",
+ "ScheduleItem",
+ "Server",
+ "ServerInfoItem",
+ "ServerResponseError",
+ "SiteItem",
+ "SiteAuthConfiguration",
+ "Sort",
+ "SubscriptionItem",
+ "TableauAuth",
+ "TableauItem",
+ "TableItem",
+ "Target",
+ "TaskItem",
+ "UserItem",
+ "ViewItem",
+ "VirtualConnectionItem",
+ "WebhookItem",
+ "WeeklyInterval",
+ "WorkbookItem",
+]
+
+from .bin import _version
+
+__version__ = _version.get_versions()["version"]
diff --git a/tableauserverclient/_version.py b/tableauserverclient/bin/_version.py
similarity index 52%
rename from tableauserverclient/_version.py
rename to tableauserverclient/bin/_version.py
index 1737a980a..f23819e86 100644
--- a/tableauserverclient/_version.py
+++ b/tableauserverclient/bin/_version.py
@@ -1,11 +1,13 @@
+
# This file helps to compute a version number in source trees obtained from
# git-archive tarball (such as those provided by githubs download-from-tag
# feature). Distribution tarballs (built by setup.py sdist) and build
# directories (produced by setup.py build) will contain a much shorter file
# that just contains the computed version number.
-# This file is released into the public domain. Generated by
-# versioneer-0.18 (https://github.com/warner/python-versioneer)
+# This file is released into the public domain.
+# Generated by versioneer-0.29
+# https://github.com/python-versioneer/python-versioneer
"""Git implementation of _version.py."""
@@ -14,9 +16,11 @@
import re
import subprocess
import sys
+from typing import Any, Callable, Dict, List, Optional, Tuple
+import functools
-def get_keywords():
+def get_keywords() -> Dict[str, str]:
"""Get the keywords needed to look up the version information."""
# these strings will be replaced by git during git-archive.
# setup.py/versioneer.py will grep for the variable names, so they must
@@ -32,14 +36,21 @@ def get_keywords():
class VersioneerConfig:
"""Container for Versioneer configuration parameters."""
+ VCS: str
+ style: str
+ tag_prefix: str
+ parentdir_prefix: str
+ versionfile_source: str
+ verbose: bool
+
-def get_config():
+def get_config() -> VersioneerConfig:
"""Create, populate and return the VersioneerConfig() object."""
# these strings are filled in when 'setup.py versioneer' creates
# _version.py
cfg = VersioneerConfig()
cfg.VCS = "git"
- cfg.style = "pep440"
+ cfg.style = "pep440-pre"
cfg.tag_prefix = "v"
cfg.parentdir_prefix = "None"
cfg.versionfile_source = "tableauserverclient/_version.py"
@@ -51,41 +62,50 @@ class NotThisMethod(Exception):
"""Exception raised if a method is not valid for the current scenario."""
-LONG_VERSION_PY = {}
-HANDLERS = {}
-
+LONG_VERSION_PY: Dict[str, str] = {}
+HANDLERS: Dict[str, Dict[str, Callable]] = {}
-def register_vcs_handler(vcs, method): # decorator
- """Decorator to mark a method as the handler for a particular VCS."""
- def decorate(f):
+def register_vcs_handler(vcs: str, method: str) -> Callable: # decorator
+ """Create decorator to mark a method as the handler of a VCS."""
+ def decorate(f: Callable) -> Callable:
"""Store f in HANDLERS[vcs][method]."""
if vcs not in HANDLERS:
HANDLERS[vcs] = {}
HANDLERS[vcs][method] = f
return f
-
return decorate
-def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env=None):
+def run_command(
+ commands: List[str],
+ args: List[str],
+ cwd: Optional[str] = None,
+ verbose: bool = False,
+ hide_stderr: bool = False,
+ env: Optional[Dict[str, str]] = None,
+) -> Tuple[Optional[str], Optional[int]]:
"""Call the given command(s)."""
assert isinstance(commands, list)
- p = None
- for c in commands:
+ process = None
+
+ popen_kwargs: Dict[str, Any] = {}
+ if sys.platform == "win32":
+ # This hides the console window if pythonw.exe is used
+ startupinfo = subprocess.STARTUPINFO()
+ startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
+ popen_kwargs["startupinfo"] = startupinfo
+
+ for command in commands:
try:
- dispcmd = str([c] + args)
+ dispcmd = str([command] + args)
# remember shell=False, so use git.cmd on windows, not just git
- p = subprocess.Popen(
- [c] + args,
- cwd=cwd,
- env=env,
- stdout=subprocess.PIPE,
- stderr=(subprocess.PIPE if hide_stderr else None),
- )
+ process = subprocess.Popen([command] + args, cwd=cwd, env=env,
+ stdout=subprocess.PIPE,
+ stderr=(subprocess.PIPE if hide_stderr
+ else None), **popen_kwargs)
break
- except EnvironmentError:
- e = sys.exc_info()[1]
+ except OSError as e:
if e.errno == errno.ENOENT:
continue
if verbose:
@@ -96,18 +116,20 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env=
if verbose:
print("unable to find command, tried %s" % (commands,))
return None, None
- stdout = p.communicate()[0].strip()
- if sys.version_info[0] >= 3:
- stdout = stdout.decode()
- if p.returncode != 0:
+ stdout = process.communicate()[0].strip().decode()
+ if process.returncode != 0:
if verbose:
print("unable to run %s (error)" % dispcmd)
print("stdout was %s" % stdout)
- return None, p.returncode
- return stdout, p.returncode
+ return None, process.returncode
+ return stdout, process.returncode
-def versions_from_parentdir(parentdir_prefix, root, verbose):
+def versions_from_parentdir(
+ parentdir_prefix: str,
+ root: str,
+ verbose: bool,
+) -> Dict[str, Any]:
"""Try to determine the version from the parent directory name.
Source tarballs conventionally unpack into a directory that includes both
@@ -116,61 +138,64 @@ def versions_from_parentdir(parentdir_prefix, root, verbose):
"""
rootdirs = []
- for i in range(3):
+ for _ in range(3):
dirname = os.path.basename(root)
if dirname.startswith(parentdir_prefix):
- return {
- "version": dirname[len(parentdir_prefix):],
- "full-revisionid": None,
- "dirty": False,
- "error": None,
- "date": None,
- }
- else:
- rootdirs.append(root)
- root = os.path.dirname(root) # up a level
+ return {"version": dirname[len(parentdir_prefix):],
+ "full-revisionid": None,
+ "dirty": False, "error": None, "date": None}
+ rootdirs.append(root)
+ root = os.path.dirname(root) # up a level
if verbose:
- print("Tried directories %s but none started with prefix %s" % (str(rootdirs), parentdir_prefix))
+ print("Tried directories %s but none started with prefix %s" %
+ (str(rootdirs), parentdir_prefix))
raise NotThisMethod("rootdir doesn't start with parentdir_prefix")
@register_vcs_handler("git", "get_keywords")
-def git_get_keywords(versionfile_abs):
+def git_get_keywords(versionfile_abs: str) -> Dict[str, str]:
"""Extract version information from the given file."""
# the code embedded in _version.py can just fetch the value of these
# keywords. When used from setup.py, we don't want to import _version.py,
# so we do it with a regexp instead. This function is not used from
# _version.py.
- keywords = {}
+ keywords: Dict[str, str] = {}
try:
- f = open(versionfile_abs, "r")
- for line in f.readlines():
- if line.strip().startswith("git_refnames ="):
- mo = re.search(r'=\s*"(.*)"', line)
- if mo:
- keywords["refnames"] = mo.group(1)
- if line.strip().startswith("git_full ="):
- mo = re.search(r'=\s*"(.*)"', line)
- if mo:
- keywords["full"] = mo.group(1)
- if line.strip().startswith("git_date ="):
- mo = re.search(r'=\s*"(.*)"', line)
- if mo:
- keywords["date"] = mo.group(1)
- f.close()
- except EnvironmentError:
+ with open(versionfile_abs, "r") as fobj:
+ for line in fobj:
+ if line.strip().startswith("git_refnames ="):
+ mo = re.search(r'=\s*"(.*)"', line)
+ if mo:
+ keywords["refnames"] = mo.group(1)
+ if line.strip().startswith("git_full ="):
+ mo = re.search(r'=\s*"(.*)"', line)
+ if mo:
+ keywords["full"] = mo.group(1)
+ if line.strip().startswith("git_date ="):
+ mo = re.search(r'=\s*"(.*)"', line)
+ if mo:
+ keywords["date"] = mo.group(1)
+ except OSError:
pass
return keywords
@register_vcs_handler("git", "keywords")
-def git_versions_from_keywords(keywords, tag_prefix, verbose):
+def git_versions_from_keywords(
+ keywords: Dict[str, str],
+ tag_prefix: str,
+ verbose: bool,
+) -> Dict[str, Any]:
"""Get version information from git keywords."""
- if not keywords:
- raise NotThisMethod("no keywords at all, weird")
+ if "refnames" not in keywords:
+ raise NotThisMethod("Short version file found")
date = keywords.get("date")
if date is not None:
+ # Use only the last line. Previous lines may contain GPG signature
+ # information.
+ date = date.splitlines()[-1]
+
# git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant
# datestamp. However we prefer "%ci" (which expands to an "ISO-8601
# -like" string, which we must then edit to make compliant), because
@@ -183,11 +208,11 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose):
if verbose:
print("keywords are unexpanded, not using")
raise NotThisMethod("unexpanded keywords, not a git-archive tarball")
- refs = set([r.strip() for r in refnames.strip("()").split(",")])
+ refs = {r.strip() for r in refnames.strip("()").split(",")}
# starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of
# just "foo-1.0". If we see a "tag: " prefix, prefer those.
TAG = "tag: "
- tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)])
+ tags = {r[len(TAG):] for r in refs if r.startswith(TAG)}
if not tags:
# Either we're using git < 1.8.3, or there really are no tags. We use
# a heuristic: assume all version tags have a digit. The old git %d
@@ -196,7 +221,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose):
# between branches and tags. By ignoring refnames without digits, we
# filter out many common branch names like "release" and
# "stabilization", as well as "HEAD" and "master".
- tags = set([r for r in refs if re.search(r"\d", r)])
+ tags = {r for r in refs if re.search(r'\d', r)}
if verbose:
print("discarding '%s', no digits" % ",".join(refs - tags))
if verbose:
@@ -205,29 +230,32 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose):
# sorting will prefer e.g. "2.0" over "2.0rc1"
if ref.startswith(tag_prefix):
r = ref[len(tag_prefix):]
+ # Filter out refs that exactly match prefix or that don't start
+ # with a number once the prefix is stripped (mostly a concern
+ # when prefix is '')
+ if not re.match(r'\d', r):
+ continue
if verbose:
print("picking %s" % r)
- return {
- "version": r,
- "full-revisionid": keywords["full"].strip(),
- "dirty": False,
- "error": None,
- "date": date,
- }
+ return {"version": r,
+ "full-revisionid": keywords["full"].strip(),
+ "dirty": False, "error": None,
+ "date": date}
# no suitable tags, so version is "0+unknown", but full hex is still there
if verbose:
print("no suitable tags, using unknown + full revision id")
- return {
- "version": "0+unknown",
- "full-revisionid": keywords["full"].strip(),
- "dirty": False,
- "error": "no suitable tags",
- "date": None,
- }
+ return {"version": "0+unknown",
+ "full-revisionid": keywords["full"].strip(),
+ "dirty": False, "error": "no suitable tags", "date": None}
@register_vcs_handler("git", "pieces_from_vcs")
-def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
+def git_pieces_from_vcs(
+ tag_prefix: str,
+ root: str,
+ verbose: bool,
+ runner: Callable = run_command
+) -> Dict[str, Any]:
"""Get version from 'git describe' in the root of the source tree.
This only gets called if the git-archive 'subst' keywords were *not*
@@ -238,7 +266,15 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
if sys.platform == "win32":
GITS = ["git.cmd", "git.exe"]
- out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, hide_stderr=True)
+ # GIT_DIR can interfere with correct operation of Versioneer.
+ # It may be intended to be passed to the Versioneer-versioned project,
+ # but that should not change where we get our version from.
+ env = os.environ.copy()
+ env.pop("GIT_DIR", None)
+ runner = functools.partial(runner, env=env)
+
+ _, rc = runner(GITS, ["rev-parse", "--git-dir"], cwd=root,
+ hide_stderr=not verbose)
if rc != 0:
if verbose:
print("Directory %s not under git control" % root)
@@ -246,33 +282,57 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
# if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty]
# if there isn't one, this yields HEX[-dirty] (no NUM)
- describe_out, rc = run_command(
- GITS,
- [
- "describe",
- "--tags",
- "--dirty",
- "--always",
- "--long",
- "--match",
- "%s*" % tag_prefix,
- ],
- cwd=root,
- )
+ describe_out, rc = runner(GITS, [
+ "describe", "--tags", "--dirty", "--always", "--long",
+ "--match", f"{tag_prefix}[[:digit:]]*"
+ ], cwd=root)
# --long was added in git-1.5.5
if describe_out is None:
raise NotThisMethod("'git describe' failed")
describe_out = describe_out.strip()
- full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root)
+ full_out, rc = runner(GITS, ["rev-parse", "HEAD"], cwd=root)
if full_out is None:
raise NotThisMethod("'git rev-parse' failed")
full_out = full_out.strip()
- pieces = {}
+ pieces: Dict[str, Any] = {}
pieces["long"] = full_out
pieces["short"] = full_out[:7] # maybe improved later
pieces["error"] = None
+ branch_name, rc = runner(GITS, ["rev-parse", "--abbrev-ref", "HEAD"],
+ cwd=root)
+ # --abbrev-ref was added in git-1.6.3
+ if rc != 0 or branch_name is None:
+ raise NotThisMethod("'git rev-parse --abbrev-ref' returned error")
+ branch_name = branch_name.strip()
+
+ if branch_name == "HEAD":
+ # If we aren't exactly on a branch, pick a branch which represents
+ # the current commit. If all else fails, we are on a branchless
+ # commit.
+ branches, rc = runner(GITS, ["branch", "--contains"], cwd=root)
+ # --contains was added in git-1.5.4
+ if rc != 0 or branches is None:
+ raise NotThisMethod("'git branch --contains' returned error")
+ branches = branches.split("\n")
+
+ # Remove the first line if we're running detached
+ if "(" in branches[0]:
+ branches.pop(0)
+
+ # Strip off the leading "* " from the list of branches.
+ branches = [branch[2:] for branch in branches]
+ if "master" in branches:
+ branch_name = "master"
+ elif not branches:
+ branch_name = None
+ else:
+ # Pick the first branch that is returned. Good or bad.
+ branch_name = branches[0]
+
+ pieces["branch"] = branch_name
+
# parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty]
# TAG might have hyphens.
git_describe = describe_out
@@ -281,16 +341,17 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
dirty = git_describe.endswith("-dirty")
pieces["dirty"] = dirty
if dirty:
- git_describe = git_describe[: git_describe.rindex("-dirty")]
+ git_describe = git_describe[:git_describe.rindex("-dirty")]
# now we have TAG-NUM-gHEX or HEX
if "-" in git_describe:
# TAG-NUM-gHEX
- mo = re.search(r"^(.+)-(\d+)-g([0-9a-f]+)$", git_describe)
+ mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe)
if not mo:
- # unparseable. Maybe git-describe is misbehaving?
- pieces["error"] = "unable to parse git-describe output: '%s'" % describe_out
+ # unparsable. Maybe git-describe is misbehaving?
+ pieces["error"] = ("unable to parse git-describe output: '%s'"
+ % describe_out)
return pieces
# tag
@@ -299,10 +360,8 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
if verbose:
fmt = "tag '%s' doesn't start with prefix '%s'"
print(fmt % (full_tag, tag_prefix))
- pieces["error"] = "tag '%s' doesn't start with prefix '%s'" % (
- full_tag,
- tag_prefix,
- )
+ pieces["error"] = ("tag '%s' doesn't start with prefix '%s'"
+ % (full_tag, tag_prefix))
return pieces
pieces["closest-tag"] = full_tag[len(tag_prefix):]
@@ -315,24 +374,27 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
else:
# HEX: no tags
pieces["closest-tag"] = None
- count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], cwd=root)
- pieces["distance"] = int(count_out) # total number of commits
+ out, rc = runner(GITS, ["rev-list", "HEAD", "--left-right"], cwd=root)
+ pieces["distance"] = len(out.split()) # total number of commits
# commit date: see ISO-8601 comment in git_versions_from_keywords()
- date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip()
+ date = runner(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip()
+ # Use only the last line. Previous lines may contain GPG signature
+ # information.
+ date = date.splitlines()[-1]
pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1)
return pieces
-def plus_or_dot(pieces):
+def plus_or_dot(pieces: Dict[str, Any]) -> str:
"""Return a + if we don't already have one, else return a ."""
if "+" in pieces.get("closest-tag", ""):
return "."
return "+"
-def render_pep440(pieces):
+def render_pep440(pieces: Dict[str, Any]) -> str:
"""Build up version string, with post-release "local version identifier".
Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you
@@ -350,29 +412,78 @@ def render_pep440(pieces):
rendered += ".dirty"
else:
# exception #1
- rendered = "0+untagged.%d.g%s" % (pieces["distance"], pieces["short"])
+ rendered = "0+untagged.%d.g%s" % (pieces["distance"],
+ pieces["short"])
if pieces["dirty"]:
rendered += ".dirty"
return rendered
-def render_pep440_pre(pieces):
- """TAG[.post.devDISTANCE] -- No -dirty.
+def render_pep440_branch(pieces: Dict[str, Any]) -> str:
+ """TAG[[.dev0]+DISTANCE.gHEX[.dirty]] .
+
+ The ".dev0" means not master branch. Note that .dev0 sorts backwards
+ (a feature branch will appear "older" than the master branch).
Exceptions:
- 1: no tags. 0.post.devDISTANCE
+ 1: no tags. 0[.dev0]+untagged.DISTANCE.gHEX[.dirty]
"""
if pieces["closest-tag"]:
rendered = pieces["closest-tag"]
+ if pieces["distance"] or pieces["dirty"]:
+ if pieces["branch"] != "master":
+ rendered += ".dev0"
+ rendered += plus_or_dot(pieces)
+ rendered += "%d.g%s" % (pieces["distance"], pieces["short"])
+ if pieces["dirty"]:
+ rendered += ".dirty"
+ else:
+ # exception #1
+ rendered = "0"
+ if pieces["branch"] != "master":
+ rendered += ".dev0"
+ rendered += "+untagged.%d.g%s" % (pieces["distance"],
+ pieces["short"])
+ if pieces["dirty"]:
+ rendered += ".dirty"
+ return rendered
+
+
+def pep440_split_post(ver: str) -> Tuple[str, Optional[int]]:
+ """Split pep440 version string at the post-release segment.
+
+ Returns the release segments before the post-release and the
+ post-release version number (or -1 if no post-release segment is present).
+ """
+ vc = str.split(ver, ".post")
+ return vc[0], int(vc[1] or 0) if len(vc) == 2 else None
+
+
+def render_pep440_pre(pieces: Dict[str, Any]) -> str:
+ """TAG[.postN.devDISTANCE] -- No -dirty.
+
+ Exceptions:
+ 1: no tags. 0.post0.devDISTANCE
+ """
+ if pieces["closest-tag"]:
if pieces["distance"]:
- rendered += ".post.dev%d" % pieces["distance"]
+ # update the post release segment
+ tag_version, post_version = pep440_split_post(pieces["closest-tag"])
+ rendered = tag_version
+ if post_version is not None:
+ rendered += ".post%d.dev%d" % (post_version + 1, pieces["distance"])
+ else:
+ rendered += ".post0.dev%d" % (pieces["distance"])
+ else:
+ # no commits, use the tag as the version
+ rendered = pieces["closest-tag"]
else:
# exception #1
- rendered = "0.post.dev%d" % pieces["distance"]
+ rendered = "0.post0.dev%d" % pieces["distance"]
return rendered
-def render_pep440_post(pieces):
+def render_pep440_post(pieces: Dict[str, Any]) -> str:
"""TAG[.postDISTANCE[.dev0]+gHEX] .
The ".dev0" means dirty. Note that .dev0 sorts backwards
@@ -399,12 +510,41 @@ def render_pep440_post(pieces):
return rendered
-def render_pep440_old(pieces):
+def render_pep440_post_branch(pieces: Dict[str, Any]) -> str:
+ """TAG[.postDISTANCE[.dev0]+gHEX[.dirty]] .
+
+ The ".dev0" means not master branch.
+
+ Exceptions:
+ 1: no tags. 0.postDISTANCE[.dev0]+gHEX[.dirty]
+ """
+ if pieces["closest-tag"]:
+ rendered = pieces["closest-tag"]
+ if pieces["distance"] or pieces["dirty"]:
+ rendered += ".post%d" % pieces["distance"]
+ if pieces["branch"] != "master":
+ rendered += ".dev0"
+ rendered += plus_or_dot(pieces)
+ rendered += "g%s" % pieces["short"]
+ if pieces["dirty"]:
+ rendered += ".dirty"
+ else:
+ # exception #1
+ rendered = "0.post%d" % pieces["distance"]
+ if pieces["branch"] != "master":
+ rendered += ".dev0"
+ rendered += "+g%s" % pieces["short"]
+ if pieces["dirty"]:
+ rendered += ".dirty"
+ return rendered
+
+
+def render_pep440_old(pieces: Dict[str, Any]) -> str:
"""TAG[.postDISTANCE[.dev0]] .
The ".dev0" means dirty.
- Eexceptions:
+ Exceptions:
1: no tags. 0.postDISTANCE[.dev0]
"""
if pieces["closest-tag"]:
@@ -421,7 +561,7 @@ def render_pep440_old(pieces):
return rendered
-def render_git_describe(pieces):
+def render_git_describe(pieces: Dict[str, Any]) -> str:
"""TAG[-DISTANCE-gHEX][-dirty].
Like 'git describe --tags --dirty --always'.
@@ -441,7 +581,7 @@ def render_git_describe(pieces):
return rendered
-def render_git_describe_long(pieces):
+def render_git_describe_long(pieces: Dict[str, Any]) -> str:
"""TAG-DISTANCE-gHEX[-dirty].
Like 'git describe --tags --dirty --always -long'.
@@ -461,26 +601,28 @@ def render_git_describe_long(pieces):
return rendered
-def render(pieces, style):
+def render(pieces: Dict[str, Any], style: str) -> Dict[str, Any]:
"""Render the given version pieces into the requested style."""
if pieces["error"]:
- return {
- "version": "unknown",
- "full-revisionid": pieces.get("long"),
- "dirty": None,
- "error": pieces["error"],
- "date": None,
- }
+ return {"version": "unknown",
+ "full-revisionid": pieces.get("long"),
+ "dirty": None,
+ "error": pieces["error"],
+ "date": None}
if not style or style == "default":
style = "pep440" # the default
if style == "pep440":
rendered = render_pep440(pieces)
+ elif style == "pep440-branch":
+ rendered = render_pep440_branch(pieces)
elif style == "pep440-pre":
rendered = render_pep440_pre(pieces)
elif style == "pep440-post":
rendered = render_pep440_post(pieces)
+ elif style == "pep440-post-branch":
+ rendered = render_pep440_post_branch(pieces)
elif style == "pep440-old":
rendered = render_pep440_old(pieces)
elif style == "git-describe":
@@ -490,16 +632,12 @@ def render(pieces, style):
else:
raise ValueError("unknown style '%s'" % style)
- return {
- "version": rendered,
- "full-revisionid": pieces["long"],
- "dirty": pieces["dirty"],
- "error": None,
- "date": pieces.get("date"),
- }
+ return {"version": rendered, "full-revisionid": pieces["long"],
+ "dirty": pieces["dirty"], "error": None,
+ "date": pieces.get("date")}
-def get_versions():
+def get_versions() -> Dict[str, Any]:
"""Get version information or return default if unable to do so."""
# I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have
# __file__, we can work backwards from there to the root. Some
@@ -510,7 +648,8 @@ def get_versions():
verbose = cfg.verbose
try:
- return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, verbose)
+ return git_versions_from_keywords(get_keywords(), cfg.tag_prefix,
+ verbose)
except NotThisMethod:
pass
@@ -519,16 +658,13 @@ def get_versions():
# versionfile_source is the relative path from the top of the source
# tree (where the .git directory might live) to this file. Invert
# this to find the root from __file__.
- for i in cfg.versionfile_source.split("/"):
+ for _ in cfg.versionfile_source.split('/'):
root = os.path.dirname(root)
except NameError:
- return {
- "version": "0+unknown",
- "full-revisionid": None,
- "dirty": None,
- "error": "unable to find root of source tree",
- "date": None,
- }
+ return {"version": "0+unknown", "full-revisionid": None,
+ "dirty": None,
+ "error": "unable to find root of source tree",
+ "date": None}
try:
pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose)
@@ -542,10 +678,6 @@ def get_versions():
except NotThisMethod:
pass
- return {
- "version": "0+unknown",
- "full-revisionid": None,
- "dirty": None,
- "error": "unable to compute version",
- "date": None,
- }
+ return {"version": "0+unknown", "full-revisionid": None,
+ "dirty": None,
+ "error": "unable to compute version", "date": None}
diff --git a/tableauserverclient/config.py b/tableauserverclient/config.py
new file mode 100644
index 000000000..a75112754
--- /dev/null
+++ b/tableauserverclient/config.py
@@ -0,0 +1,27 @@
+import os
+
+ALLOWED_FILE_EXTENSIONS = ["tds", "tdsx", "tde", "hyper", "parquet"]
+
+BYTES_PER_MB = 1024 * 1024
+
+DELAY_SLEEP_SECONDS = 0.1
+
+
+class Config:
+ # The maximum size of a file that can be published in a single request is 64MB
+ @property
+ def FILESIZE_LIMIT_MB(self):
+ return min(int(os.getenv("TSC_FILESIZE_LIMIT_MB", 64)), 64)
+
+ # For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks
+ @property
+ def CHUNK_SIZE_MB(self):
+ return int(os.getenv("TSC_CHUNK_SIZE_MB", 5 * 10)) # 5MB felt too slow, upped it to 50
+
+ # Default page size
+ @property
+ def PAGE_SIZE(self):
+ return int(os.getenv("TSC_PAGE_SIZE", 100))
+
+
+config = Config()
diff --git a/tableauserverclient/datetime_helpers.py b/tableauserverclient/datetime_helpers.py
index 2b1df202c..00f62faf8 100644
--- a/tableauserverclient/datetime_helpers.py
+++ b/tableauserverclient/datetime_helpers.py
@@ -1,12 +1,16 @@
import datetime
-# This code below is from the python documentation for
-# tzinfo: https://docs.python.org/2.3/lib/datetime-tzinfo.html
ZERO = datetime.timedelta(0)
HOUR = datetime.timedelta(hours=1)
+def timestamp():
+ return datetime.datetime.now().strftime("%H:%M:%S")
+
+
+# This class is a concrete implementation of the abstract base class tzinfo
+# docs: https://docs.python.org/2.3/lib/datetime-tzinfo.html
class UTC(datetime.tzinfo):
"""UTC"""
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/helpers/__init__.py b/tableauserverclient/helpers/__init__.py
new file mode 100644
index 000000000..7daf0d490
--- /dev/null
+++ b/tableauserverclient/helpers/__init__.py
@@ -0,0 +1 @@
+from .strings import *
diff --git a/tableauserverclient/helpers/headers.py b/tableauserverclient/helpers/headers.py
new file mode 100644
index 000000000..2ed4a814d
--- /dev/null
+++ b/tableauserverclient/helpers/headers.py
@@ -0,0 +1,17 @@
+from copy import deepcopy
+from urllib.parse import unquote_plus
+
+
+def fix_filename(params):
+ if "filename*" not in params:
+ return params
+
+ params = deepcopy(params)
+ filename = params["filename*"]
+ prefix = "UTF-8''"
+ if filename.startswith(prefix):
+ filename = filename[len(prefix) :]
+
+ params["filename"] = unquote_plus(filename)
+ del params["filename*"]
+ return params
diff --git a/tableauserverclient/helpers/logging.py b/tableauserverclient/helpers/logging.py
new file mode 100644
index 000000000..e64c6d2c8
--- /dev/null
+++ b/tableauserverclient/helpers/logging.py
@@ -0,0 +1,4 @@
+import logging
+
+# TODO change: this defaults to logging *everything* to stdout
+logger = logging.getLogger("TSC")
diff --git a/tableauserverclient/helpers/strings.py b/tableauserverclient/helpers/strings.py
new file mode 100644
index 000000000..6ba4e48d9
--- /dev/null
+++ b/tableauserverclient/helpers/strings.py
@@ -0,0 +1,67 @@
+from defusedxml.ElementTree import fromstring, tostring
+from functools import singledispatch
+from typing import TypeVar, overload
+
+
+# the redact method can handle either strings or bytes, but it can't mix them.
+# Generic type so we can write the actual logic once, then use singledispatch to
+# create the replacement text with the correct type
+T = TypeVar("T", str, bytes)
+
+
+def _redact_any_type(xml: T, sensitive_word: T, replacement: T, encoding=None) -> T:
+ try:
+ root = fromstring(xml)
+ matches = root.findall(".//*[@password]")
+ for item in matches:
+ item.attrib["password"] = "********"
+ matches = root.findall(".//password")
+ for item in matches:
+ item.text = "********"
+ # tostring returns bytes unless an encoding value is passed
+ return tostring(root, encoding=encoding)
+ except Exception:
+ # something about the xml handling failed. Just cut off the text at the first occurrence of "password"
+ location = xml.find(sensitive_word)
+ return xml[:location] + replacement
+
+
+@singledispatch
+def redact_xml(content):
+ # this will only be called if it didn't get directed to the str or bytes overloads
+ raise TypeError("Redaction only works on xml saved as str or bytes")
+
+
+@redact_xml.register
+def _(xml: str) -> str:
+ out = _redact_any_type(xml, "password", "...[redacted]", encoding="unicode")
+ return out
+
+
+@redact_xml.register # type: ignore[no-redef]
+def _(xml: bytes) -> bytes:
+ return _redact_any_type(bytearray(xml), b"password", b"..[redacted]")
+
+
+@overload
+def nullable_str_to_int(value: None) -> None: ...
+
+
+@overload
+def nullable_str_to_int(value: str) -> int: ...
+
+
+def nullable_str_to_int(value):
+ return int(value) if value is not None else None
+
+
+@overload
+def nullable_str_to_bool(value: None) -> None: ...
+
+
+@overload
+def nullable_str_to_bool(value: str) -> bool: ...
+
+
+def nullable_str_to_bool(value):
+ return str(value).lower() == "true" if value is not None else None
diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py
index e5945782d..30cd88104 100644
--- a/tableauserverclient/models/__init__.py
+++ b/tableauserverclient/models/__init__.py
@@ -1,38 +1,111 @@
-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 .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 .interval_item import (
+from tableauserverclient.models.column_item import ColumnItem
+from tableauserverclient.models.connection_credentials import ConnectionCredentials
+from tableauserverclient.models.connection_item import ConnectionItem
+from tableauserverclient.models.custom_view_item import CustomViewItem
+from tableauserverclient.models.data_acceleration_report_item import DataAccelerationReportItem
+from tableauserverclient.models.data_alert_item import DataAlertItem
+from tableauserverclient.models.database_item import DatabaseItem
+from tableauserverclient.models.data_freshness_policy_item import DataFreshnessPolicyItem
+from tableauserverclient.models.datasource_item import DatasourceItem
+from tableauserverclient.models.dqw_item import DQWItem
+from tableauserverclient.models.exceptions import UnpopulatedPropertyError
+from tableauserverclient.models.favorites_item import FavoriteItem
+from tableauserverclient.models.fileupload_item import FileuploadItem
+from tableauserverclient.models.flow_item import FlowItem
+from tableauserverclient.models.flow_run_item import FlowRunItem
+from tableauserverclient.models.group_item import GroupItem
+from tableauserverclient.models.groupset_item import GroupSetItem
+from tableauserverclient.models.interval_item import (
IntervalItem,
DailyInterval,
WeeklyInterval,
MonthlyInterval,
HourlyInterval,
)
-from .job_item import JobItem, BackgroundJobItem
-from .pagination_item import PaginationItem
-from .project_item import ProjectItem
-from .schedule_item import ScheduleItem
-from .server_info_item import ServerInfoItem
-from .site_item import SiteItem
-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 tableauserverclient.models.job_item import JobItem, BackgroundJobItem
+from tableauserverclient.models.linked_tasks_item import (
+ LinkedTaskItem,
+ LinkedTaskStepItem,
+ LinkedTaskFlowRunItem,
+)
+from tableauserverclient.models.location_item import LocationItem
+from tableauserverclient.models.metric_item import MetricItem
+from tableauserverclient.models.pagination_item import PaginationItem
+from tableauserverclient.models.permissions_item import PermissionsRule, Permission
+from tableauserverclient.models.project_item import ProjectItem
+from tableauserverclient.models.revision_item import RevisionItem
+from tableauserverclient.models.schedule_item import ScheduleItem
+from tableauserverclient.models.server_info_item import ServerInfoItem
+from tableauserverclient.models.site_item import SiteItem, SiteAuthConfiguration
+from tableauserverclient.models.subscription_item import SubscriptionItem
+from tableauserverclient.models.table_item import TableItem
+from tableauserverclient.models.tableau_auth import Credentials, TableauAuth, PersonalAccessTokenAuth, JWTAuth
+from tableauserverclient.models.tableau_types import Resource, TableauItem, plural_type
+from tableauserverclient.models.tag_item import TagItem
+from tableauserverclient.models.target import Target
+from tableauserverclient.models.task_item import TaskItem
+from tableauserverclient.models.user_item import UserItem
+from tableauserverclient.models.view_item import ViewItem
+from tableauserverclient.models.virtual_connection_item import VirtualConnectionItem
+from tableauserverclient.models.webhook_item import WebhookItem
+from tableauserverclient.models.workbook_item import WorkbookItem
+from tableauserverclient.models.extract_item import ExtractItem
+
+__all__ = [
+ "ColumnItem",
+ "ConnectionCredentials",
+ "ConnectionItem",
+ "Credentials",
+ "CustomViewItem",
+ "DataAccelerationReportItem",
+ "DataAlertItem",
+ "DatabaseItem",
+ "DataFreshnessPolicyItem",
+ "DatasourceItem",
+ "DQWItem",
+ "UnpopulatedPropertyError",
+ "FavoriteItem",
+ "FileuploadItem",
+ "FlowItem",
+ "FlowRunItem",
+ "GroupItem",
+ "GroupSetItem",
+ "IntervalItem",
+ "JobItem",
+ "DailyInterval",
+ "WeeklyInterval",
+ "MonthlyInterval",
+ "HourlyInterval",
+ "BackgroundJobItem",
+ "LocationItem",
+ "MetricItem",
+ "PaginationItem",
+ "Permission",
+ "PermissionsRule",
+ "ProjectItem",
+ "RevisionItem",
+ "ScheduleItem",
+ "ServerInfoItem",
+ "SiteAuthConfiguration",
+ "SiteItem",
+ "SubscriptionItem",
+ "TableItem",
+ "TableauAuth",
+ "PersonalAccessTokenAuth",
+ "JWTAuth",
+ "Resource",
+ "TableauItem",
+ "plural_type",
+ "TagItem",
+ "Target",
+ "TaskItem",
+ "UserItem",
+ "ViewItem",
+ "VirtualConnectionItem",
+ "WebhookItem",
+ "WorkbookItem",
+ "LinkedTaskItem",
+ "LinkedTaskStepItem",
+ "LinkedTaskFlowRunItem",
+ "ExtractItem",
+]
diff --git a/tableauserverclient/models/column_item.py b/tableauserverclient/models/column_item.py
index a95d005ca..3a7416e28 100644
--- a/tableauserverclient/models/column_item.py
+++ b/tableauserverclient/models/column_item.py
@@ -1,14 +1,17 @@
-import xml.etree.ElementTree as ET
+from defusedxml.ElementTree import fromstring
from .property_decorators import property_not_empty
-class ColumnItem(object):
+class ColumnItem:
def __init__(self, name, description=None):
self._id = None
self.description = description
self.name = name
+ def __repr__(self):
+ return f"<{self.__class__.__name__} {self._id} {self.name} {self.description}>"
+
@property
def id(self):
return self._id
@@ -47,7 +50,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_credentials.py b/tableauserverclient/models/connection_credentials.py
index db65de0ad..aaa2f1bed 100644
--- a/tableauserverclient/models/connection_credentials.py
+++ b/tableauserverclient/models/connection_credentials.py
@@ -1,12 +1,28 @@
from .property_decorators import property_is_boolean
-class ConnectionCredentials(object):
- """Connection Credentials for Workbooks and Datasources publish request.
+class ConnectionCredentials:
+ """
+ Connection Credentials for Workbooks and Datasources publish request.
Consider removing this object and other variables holding secrets
as soon as possible after use to avoid them hanging around in memory.
+ Parameters
+ ----------
+ name: str
+ The username for the connection.
+
+ password: str
+ The password used for the connection.
+
+ embed: bool, default True
+ Determines whether to embed the password (True) for the workbook or data source connection or not (False).
+
+ oauth: bool, default False
+ Determines whether to use OAuth for the connection (True) or not (False).
+ For more information see: https://help.tableau.com/current/server/en-us/protected_auth.htm
+
"""
def __init__(self, name, password, embed=True, oauth=False):
@@ -15,6 +31,13 @@ def __init__(self, name, password, embed=True, oauth=False):
self.embed = embed
self.oauth = oauth
+ def __repr__(self):
+ if self.password:
+ print = "redacted"
+ else:
+ print = "None"
+ return f"<{self.__class__.__name__} name={self.name} password={print} embed={self.embed} oauth={self.oauth} >"
+
@property
def embed(self):
return self._embed
diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py
index 018c093c7..6a8244fb1 100644
--- a/tableauserverclient/models/connection_item.py
+++ b/tableauserverclient/models/connection_item.py
@@ -1,54 +1,117 @@
-import xml.etree.ElementTree as ET
+import logging
+from typing import Optional
+
+from defusedxml.ElementTree import fromstring
+
from .connection_credentials import ConnectionCredentials
+from .property_decorators import property_is_boolean
+from tableauserverclient.helpers.logging import logger
+
+
+class ConnectionItem:
+ """
+ Corresponds to workbook and data source connections.
+
+ Attributes
+ ----------
+ datasource_id: str
+ The identifier of the data source.
+
+ datasource_name: str
+ The name of the data source.
+
+ id: str
+ The identifier of the connection.
+
+ connection_type: str
+ The type of connection.
+
+ username: str
+ The username for the connection. (see ConnectionCredentials)
+
+ password: str
+ The password used for the connection. (see ConnectionCredentials)
+ embed_password: bool
+ Determines whether to embed the password (True) for the workbook or data source connection or not (False). (see ConnectionCredentials)
+
+ server_address: str
+ The server address for the connection.
+
+ server_port: str
+ The port used for the connection.
+
+ connection_credentials: ConnectionCredentials
+ The Connection Credentials object containing authentication details for
+ the connection. Replaces username/password/embed_password when
+ publishing a flow, document or workbook file in the request body.
+ """
-class ConnectionItem(object):
def __init__(self):
- self._datasource_id = None
- self._datasource_name = None
- self._id = None
- self._connection_type = None
- self.embed_password = None
- self.password = None
- self.server_address = None
- self.server_port = None
- self.username = None
- self.connection_credentials = None
+ self._datasource_id: Optional[str] = None
+ self._datasource_name: Optional[str] = None
+ self._id: Optional[str] = None
+ self._connection_type: Optional[str] = None
+ self.embed_password: bool = None
+ self.password: Optional[str] = None
+ self.server_address: Optional[str] = None
+ self.server_port: Optional[str] = None
+ self.username: Optional[str] = None
+ self.connection_credentials: Optional[ConnectionCredentials] = None
+ self._query_tagging: Optional[bool] = None
@property
- def datasource_id(self):
+ def datasource_id(self) -> Optional[str]:
return self._datasource_id
@property
- def datasource_name(self):
+ def datasource_name(self) -> Optional[str]:
return self._datasource_name
@property
- def id(self):
+ def id(self) -> Optional[str]:
return self._id
@property
- def connection_type(self):
+ def connection_type(self) -> Optional[str]:
return self._connection_type
+ @property
+ def query_tagging(self) -> Optional[bool]:
+ return self._query_tagging
+
+ @query_tagging.setter
+ @property_is_boolean
+ def query_tagging(self, value: Optional[bool]):
+ # if connection type = hyper, Snowflake, or Teradata, we can't change this value: it is always true
+ if self._connection_type in ["hyper", "snowflake", "teradata"]:
+ logger.debug(
+ f"Cannot update value: Query tagging is always enabled for {self._connection_type} connections"
+ )
+ return
+ self._query_tagging = value
+
def __repr__(self):
- return "".format(
+ return "".format(
**self.__dict__
)
@classmethod
- def from_response(cls, resp, ns):
+ def from_response(cls, resp, ns) -> list["ConnectionItem"]:
all_connection_items = list()
- parsed_response = 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()
- connection_item._id = connection_xml.get("id", None)
- connection_item._connection_type = connection_xml.get("type", None)
+ connection_item._id = connection_xml.get("id", connection_xml.get("connectionId", None))
+ connection_item._connection_type = connection_xml.get("type", connection_xml.get("dbClass", None))
connection_item.embed_password = string_to_bool(connection_xml.get("embedPassword", ""))
- connection_item.server_address = connection_xml.get("serverAddress", None)
- connection_item.server_port = connection_xml.get("serverPort", None)
+ connection_item.server_address = connection_xml.get("serverAddress", connection_xml.get("server", None))
+ connection_item.server_port = connection_xml.get("serverPort", connection_xml.get("port", None))
connection_item.username = connection_xml.get("userName", None)
+ connection_item._query_tagging = (
+ string_to_bool(s) if (s := connection_xml.get("queryTagging", None)) else None
+ )
datasource_elem = connection_xml.find(".//t:datasource", namespaces=ns)
if datasource_elem is not None:
connection_item._datasource_id = datasource_elem.get("id", None)
@@ -57,7 +120,7 @@ def from_response(cls, resp, ns):
return all_connection_items
@classmethod
- def from_xml_element(cls, parsed_response, ns):
+ def from_xml_element(cls, parsed_response, ns) -> list["ConnectionItem"]:
"""
@@ -68,7 +131,7 @@ def from_xml_element(cls, parsed_response, ns):
"""
- all_connection_items = list()
+ all_connection_items: list["ConnectionItem"] = list()
all_connection_xml = parsed_response.findall(".//t:connection", namespaces=ns)
for connection_xml in all_connection_xml:
@@ -80,12 +143,13 @@ def from_xml_element(cls, parsed_response, ns):
connection_credentials = connection_xml.find(".//t:connectionCredentials", namespaces=ns)
if connection_credentials is not None:
-
- connection_item.connection_credentials = ConnectionCredentials.from_xml_element(connection_credentials)
+ connection_item.connection_credentials = ConnectionCredentials.from_xml_element(
+ connection_credentials, ns
+ )
return all_connection_items
# Used to convert string represented boolean to a boolean type
-def string_to_bool(s):
- return s.lower() == "true"
+def string_to_bool(s: str) -> bool:
+ return s is not None and s.lower() == "true"
diff --git a/tableauserverclient/models/custom_view_item.py b/tableauserverclient/models/custom_view_item.py
new file mode 100644
index 000000000..5cafe469c
--- /dev/null
+++ b/tableauserverclient/models/custom_view_item.py
@@ -0,0 +1,228 @@
+from datetime import datetime
+
+from defusedxml import ElementTree
+from defusedxml.ElementTree import fromstring, tostring
+from typing import Callable, Optional
+from collections.abc import Iterator
+
+from tableauserverclient.models.exceptions import UnpopulatedPropertyError
+from tableauserverclient.models.user_item import UserItem
+from tableauserverclient.models.view_item import ViewItem
+from tableauserverclient.models.workbook_item import WorkbookItem
+from tableauserverclient.datetime_helpers import parse_datetime
+
+
+class CustomViewItem:
+ """
+ Represents a Custom View item on Tableau Server.
+
+ Parameters
+ ----------
+ id : Optional[str]
+ The ID of the Custom View item.
+
+ name : Optional[str]
+ The name of the Custom View item.
+
+ Attributes
+ ----------
+ content_url : Optional[str]
+ The content URL of the Custom View item.
+
+ created_at : Optional[datetime]
+ The date and time the Custom View item was created.
+
+ image: bytes
+ The image of the Custom View item. Must be populated first.
+
+ pdf: bytes
+ The PDF of the Custom View item. Must be populated first.
+
+ csv: Iterator[bytes]
+ The CSV of the Custom View item. Must be populated first.
+
+ shared : Optional[bool]
+ Whether the Custom View item is shared.
+
+ updated_at : Optional[datetime]
+ The date and time the Custom View item was last updated.
+
+ owner : Optional[UserItem]
+ The id of the owner of the Custom View item.
+
+ workbook : Optional[WorkbookItem]
+ The id of the workbook the Custom View item belongs to.
+
+ view : Optional[ViewItem]
+ The id of the view the Custom View item belongs to.
+ """
+
+ def __init__(self, id: Optional[str] = None, name: Optional[str] = None) -> None:
+ self._content_url: Optional[str] = None # ?
+ self._created_at: Optional["datetime"] = None
+ self._id: Optional[str] = id
+ self._image: Optional[Callable[[], bytes]] = None
+ self._pdf: Optional[Callable[[], bytes]] = None
+ self._csv: Optional[Callable[[], Iterator[bytes]]] = None
+ self._name: Optional[str] = name
+ self._shared: Optional[bool] = False
+ self._updated_at: Optional["datetime"] = None
+
+ self._owner: Optional[UserItem] = None
+ self._view: Optional[ViewItem] = None
+ self._workbook: Optional[WorkbookItem] = None
+
+ def __repr__(self: "CustomViewItem"):
+ view_info = ""
+ if self._view:
+ view_info = " view='{}'".format(self._view.name or self._view.id or "unknown")
+ wb_info = ""
+ if self._workbook:
+ wb_info = " workbook='{}'".format(self._workbook.name or self._workbook.id or "unknown")
+ owner_info = ""
+ if self._owner:
+ owner_info = " owner='{}'".format(self._owner.name or self._owner.id or "unknown")
+ return f""
+
+ def _set_image(self, image):
+ self._image = image
+
+ def _set_pdf(self, pdf):
+ self._pdf = pdf
+
+ def _set_csv(self, csv):
+ self._csv = csv
+
+ @property
+ def content_url(self) -> Optional[str]:
+ return self._content_url
+
+ @property
+ def created_at(self) -> Optional["datetime"]:
+ return self._created_at
+
+ @property
+ def id(self) -> Optional[str]:
+ return self._id
+
+ @property
+ def image(self) -> bytes:
+ if self._image is None:
+ error = "Custom View item must be populated with its png image first."
+ raise UnpopulatedPropertyError(error)
+ return self._image()
+
+ @property
+ def pdf(self) -> bytes:
+ if self._pdf is None:
+ error = "Custom View item must be populated with its pdf first."
+ raise UnpopulatedPropertyError(error)
+ return self._pdf()
+
+ @property
+ def csv(self) -> Iterator[bytes]:
+ if self._csv is None:
+ error = "Custom View item must be populated with its csv first."
+ raise UnpopulatedPropertyError(error)
+ return self._csv()
+
+ @property
+ def name(self) -> Optional[str]:
+ return self._name
+
+ @name.setter
+ def name(self, value: str):
+ self._name = value
+
+ @property
+ def shared(self) -> Optional[bool]:
+ return self._shared
+
+ @shared.setter
+ def shared(self, value: bool):
+ self._shared = value
+
+ @property
+ def updated_at(self) -> Optional["datetime"]:
+ return self._updated_at
+
+ @property
+ def owner(self) -> Optional[UserItem]:
+ return self._owner
+
+ @owner.setter
+ def owner(self, value: UserItem):
+ self._owner = value
+
+ @property
+ def workbook(self) -> Optional[WorkbookItem]:
+ return self._workbook
+
+ @property
+ def view(self) -> Optional[ViewItem]:
+ return self._view
+
+ @classmethod
+ def from_response(cls, resp, ns, workbook_id="") -> Optional["CustomViewItem"]:
+ item = cls.list_from_response(resp, ns, workbook_id)
+ if not item or len(item) == 0:
+ return None
+ else:
+ return item[0]
+
+ @classmethod
+ def list_from_response(cls, resp, ns, workbook_id="") -> list["CustomViewItem"]:
+ return cls.from_xml_element(fromstring(resp), ns, workbook_id)
+
+ """
+
+
+
+
+
+ """
+
+ @classmethod
+ def from_xml_element(cls, parsed_response, ns, workbook_id="") -> list["CustomViewItem"]:
+ all_view_items = list()
+ all_view_xml = parsed_response.findall(".//t:customView", namespaces=ns)
+ for custom_view_xml in all_view_xml:
+ cv_item = cls()
+ view_elem: ElementTree = custom_view_xml.find(".//t:view", namespaces=ns)
+ workbook_elem: str = custom_view_xml.find(".//t:workbook", namespaces=ns)
+ owner_elem: str = custom_view_xml.find(".//t:owner", namespaces=ns)
+ cv_item._created_at = parse_datetime(custom_view_xml.get("createdAt", None))
+ cv_item._updated_at = parse_datetime(custom_view_xml.get("updatedAt", None))
+ cv_item._content_url = custom_view_xml.get("contentUrl", None)
+ cv_item._id = custom_view_xml.get("id", None)
+ cv_item._name = custom_view_xml.get("name", None)
+ cv_item._shared = string_to_bool(custom_view_xml.get("shared", None))
+
+ if owner_elem is not None:
+ parsed_owners = UserItem.from_response_as_owner(tostring(custom_view_xml), ns)
+ if parsed_owners and len(parsed_owners) > 0:
+ cv_item._owner = parsed_owners[0]
+
+ if view_elem is not None:
+ parsed_views = ViewItem.from_response(tostring(custom_view_xml), ns)
+ if parsed_views and len(parsed_views) > 0:
+ cv_item._view = parsed_views[0]
+
+ if workbook_id:
+ cv_item._workbook = WorkbookItem(workbook_id)
+ elif workbook_elem is not None:
+ parsed_workbooks = WorkbookItem.from_response(tostring(custom_view_xml), ns)
+ if parsed_workbooks and len(parsed_workbooks) > 0:
+ cv_item._workbook = parsed_workbooks[0]
+
+ all_view_items.append(cv_item)
+ return all_view_items
+
+
+def string_to_bool(s: Optional[str]) -> bool:
+ return (s or "").lower() == "true"
diff --git a/tableauserverclient/models/data_acceleration_report_item.py b/tableauserverclient/models/data_acceleration_report_item.py
index eab6c24cd..3a8883bed 100644
--- a/tableauserverclient/models/data_acceleration_report_item.py
+++ b/tableauserverclient/models/data_acceleration_report_item.py
@@ -1,8 +1,8 @@
-import xml.etree.ElementTree as ET
+from defusedxml.ElementTree import fromstring
-class DataAccelerationReportItem(object):
- class ComparisonRecord(object):
+class DataAccelerationReportItem:
+ class ComparisonRecord:
def __init__(
self,
site,
@@ -46,6 +46,9 @@ def avg_non_accelerated_plt(self):
def __init__(self, comparison_records):
self._comparison_records = comparison_records
+ def __repr__(self):
+ return f"<(deprecated)DataAccelerationReportItem site={self.site} sheet={sheet_uri}>"
+
@property
def comparison_records(self):
return self._comparison_records
@@ -70,7 +73,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..7285ee609 100644
--- a/tableauserverclient/models/data_alert_item.py
+++ b/tableauserverclient/models/data_alert_item.py
@@ -1,15 +1,16 @@
-import xml.etree.ElementTree as ET
+from datetime import datetime
+from typing import Optional
+
+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
-class DataAlertItem(object):
+class DataAlertItem:
class Frequency:
Once = "Once"
Frequently = "Frequently"
@@ -18,35 +19,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 +56,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 +174,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/data_freshness_policy_item.py b/tableauserverclient/models/data_freshness_policy_item.py
new file mode 100644
index 000000000..6e0cb9001
--- /dev/null
+++ b/tableauserverclient/models/data_freshness_policy_item.py
@@ -0,0 +1,210 @@
+import xml.etree.ElementTree as ET
+
+from typing import Optional
+from tableauserverclient.models.property_decorators import property_is_enum, property_not_nullable
+from .interval_item import IntervalItem
+
+
+class DataFreshnessPolicyItem:
+ class Option:
+ AlwaysLive = "AlwaysLive"
+ SiteDefault = "SiteDefault"
+ FreshEvery = "FreshEvery"
+ FreshAt = "FreshAt"
+
+ class FreshEvery:
+ class Frequency:
+ Minutes = "Minutes"
+ Hours = "Hours"
+ Days = "Days"
+ Weeks = "Weeks"
+
+ def __init__(self, frequency: str, value: int):
+ self.frequency: str = frequency
+ self.value: int = value
+
+ def __repr__(self):
+ return "".format(**vars(self))
+
+ @property
+ def frequency(self) -> str:
+ return self._frequency
+
+ @frequency.setter
+ @property_is_enum(Frequency)
+ def frequency(self, value: str):
+ self._frequency = value
+
+ @classmethod
+ def from_xml_element(cls, fresh_every_schedule_elem: ET.Element):
+ frequency = fresh_every_schedule_elem.get("frequency", None)
+ value_str = fresh_every_schedule_elem.get("value", None)
+ if (frequency is None) or (value_str is None):
+ return None
+ value = int(value_str)
+ return DataFreshnessPolicyItem.FreshEvery(frequency, value)
+
+ class FreshAt:
+ class Frequency:
+ Day = "Day"
+ Week = "Week"
+ Month = "Month"
+
+ def __init__(self, frequency: str, time: str, timezone, interval_item: Optional[list[str]] = None):
+ self.frequency = frequency
+ self.time = time
+ self.timezone = timezone
+ self.interval_item: Optional[list[str]] = interval_item
+
+ def __repr__(self):
+ return (
+ " timezone={_timezone} " "interval_item={_interval_time}"
+ ).format(**vars(self))
+
+ @property
+ def interval_item(self) -> Optional[list[str]]:
+ return self._interval_item
+
+ @interval_item.setter
+ def interval_item(self, value: list[str]):
+ self._interval_item = value
+
+ @property
+ def time(self):
+ return self._time
+
+ @time.setter
+ @property_not_nullable
+ def time(self, value):
+ self._time = value
+
+ @property
+ def timezone(self) -> str:
+ return self._timezone
+
+ @timezone.setter
+ def timezone(self, value: str):
+ self._timezone = value
+
+ @property
+ def frequency(self) -> str:
+ return self._frequency
+
+ @frequency.setter
+ @property_is_enum(Frequency)
+ def frequency(self, value: str):
+ self._frequency = value
+
+ @classmethod
+ def from_xml_element(cls, fresh_at_schedule_elem: ET.Element, ns):
+ frequency = fresh_at_schedule_elem.get("frequency", None)
+ time = fresh_at_schedule_elem.get("time", None)
+ if (frequency is None) or (time is None):
+ return None
+ timezone = fresh_at_schedule_elem.get("timezone", None)
+ interval = parse_intervals(fresh_at_schedule_elem, frequency, ns)
+ return DataFreshnessPolicyItem.FreshAt(frequency, time, timezone, interval)
+
+ def __init__(self, option: str):
+ self.option = option
+ self.fresh_every_schedule: Optional[DataFreshnessPolicyItem.FreshEvery] = None
+ self.fresh_at_schedule: Optional[DataFreshnessPolicyItem.FreshAt] = None
+
+ def __repr__(self):
+ return "".format(**vars(self))
+
+ @property
+ def option(self) -> str:
+ return self._option
+
+ @option.setter
+ @property_is_enum(Option)
+ def option(self, value: str):
+ self._option = value
+
+ @property
+ def fresh_every_schedule(self) -> Optional[FreshEvery]:
+ return self._fresh_every_schedule
+
+ @fresh_every_schedule.setter
+ def fresh_every_schedule(self, value: FreshEvery):
+ self._fresh_every_schedule = value
+
+ @property
+ def fresh_at_schedule(self) -> Optional[FreshAt]:
+ return self._fresh_at_schedule
+
+ @fresh_at_schedule.setter
+ def fresh_at_schedule(self, value: FreshAt):
+ self._fresh_at_schedule = value
+
+ @classmethod
+ def from_xml_element(cls, data_freshness_policy_elem, ns):
+ option = data_freshness_policy_elem.get("option", None)
+ if option is None:
+ return None
+ data_freshness_policy = DataFreshnessPolicyItem(option)
+
+ fresh_at_schedule = None
+ fresh_every_schedule = None
+ if option == "FreshAt":
+ fresh_at_schedule_elem = data_freshness_policy_elem.find(".//t:freshAtSchedule", namespaces=ns)
+ fresh_at_schedule = DataFreshnessPolicyItem.FreshAt.from_xml_element(fresh_at_schedule_elem, ns)
+ data_freshness_policy.fresh_at_schedule = fresh_at_schedule
+ elif option == "FreshEvery":
+ fresh_every_schedule_elem = data_freshness_policy_elem.find(".//t:freshEverySchedule", namespaces=ns)
+ fresh_every_schedule = DataFreshnessPolicyItem.FreshEvery.from_xml_element(fresh_every_schedule_elem)
+ data_freshness_policy.fresh_every_schedule = fresh_every_schedule
+
+ return data_freshness_policy
+
+
+def parse_intervals(intervals_elem, frequency, ns):
+ interval_elems = intervals_elem.findall(".//t:intervals/t:interval", namespaces=ns)
+ interval = []
+ for interval_elem in interval_elems:
+ interval.extend(interval_elem.attrib.items())
+
+ # No intervals expected for Day frequency
+ if frequency == DataFreshnessPolicyItem.FreshAt.Frequency.Day:
+ return None
+
+ if frequency == DataFreshnessPolicyItem.FreshAt.Frequency.Week:
+ interval_values = [(i[1]).title() for i in interval]
+ return parse_week_intervals(interval_values)
+
+ if frequency == DataFreshnessPolicyItem.FreshAt.Frequency.Month:
+ interval_values = [(i[1]) for i in interval]
+ return parse_month_intervals(interval_values)
+
+
+def parse_week_intervals(interval_values):
+ # Using existing IntervalItem.Day to check valid weekday string
+ if not all(hasattr(IntervalItem.Day, day) for day in interval_values):
+ raise ValueError("Invalid week day defined " + str(interval_values))
+ return interval_values
+
+
+def parse_month_intervals(interval_values):
+ error = f"Invalid interval value for a monthly frequency: {interval_values}."
+
+ # Month interval can have value either only ['LastDay'] or list of dates e.g. ["1", 20", "30"]
+ # First check if the list only have LastDay value. When using LastDay, there shouldn't be
+ # any other values, hence checking the first element of the list is enough.
+ # If the value is not "LastDay", we assume intervals is on list of dates format.
+ # We created this function instead of using existing MonthlyInterval because we allow list of dates interval,
+
+ intervals = []
+ if interval_values[0] == "LastDay":
+ intervals.append(interval_values[0])
+ else:
+ for interval in interval_values:
+ try:
+ if 1 <= int(interval) <= 31:
+ intervals.append(interval)
+ else:
+ raise ValueError(error)
+ except ValueError:
+ if interval_values[0] != "LastDay":
+ raise ValueError(error)
+ return intervals
diff --git a/tableauserverclient/models/database_item.py b/tableauserverclient/models/database_item.py
index 4934af81b..4d4604461 100644
--- a/tableauserverclient/models/database_item.py
+++ b/tableauserverclient/models/database_item.py
@@ -1,14 +1,16 @@
-import xml.etree.ElementTree as ET
+import logging
+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):
+class DatabaseItem:
class ContentPermissions:
LockedToProject = "LockedToDatabase"
ManagedByOwner = "ManagedByOwner"
@@ -42,6 +44,12 @@ def __init__(self, name, description=None, content_permissions=None):
self._tables = None # Not implemented yet
+ def __str__(self):
+ return f""
+
+ def __repr__(self):
+ return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}"
+
@property
def dqws(self):
if self._data_quality_warnings is None:
@@ -53,6 +61,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 +80,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
@@ -242,11 +250,13 @@ def _set_tables(self, tables):
self._tables = tables
def _set_default_permissions(self, permissions, content_type):
+ attr = f"_default_{content_type}_permissions"
setattr(
self,
- "_default_{content}_permissions".format(content=content_type),
+ attr,
permissions,
)
+ logging.getLogger().debug({"type": attr, "value": getattr(self, attr)})
def _set_data_quality_warnings(self, dqw):
self._data_quality_warnings = dqw
@@ -254,7 +264,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..5501ee332 100644
--- a/tableauserverclient/models/datasource_item.py
+++ b/tableauserverclient/models/datasource_item.py
@@ -1,54 +1,192 @@
+import copy
+import datetime
import xml.etree.ElementTree as ET
-from .exceptions import UnpopulatedPropertyError
-from .property_decorators import (
+from typing import Optional
+
+from defusedxml.ElementTree import fromstring
+
+from tableauserverclient.datetime_helpers import parse_datetime
+from tableauserverclient.helpers.strings import nullable_str_to_bool, nullable_str_to_int
+from tableauserverclient.models.connection_item import ConnectionItem
+from tableauserverclient.models.exceptions import UnpopulatedPropertyError
+from tableauserverclient.models.permissions_item import PermissionsRule
+from tableauserverclient.models.project_item import ProjectItem
+from tableauserverclient.models.property_decorators import (
property_not_nullable,
property_is_boolean,
property_is_enum,
)
-from .tag_item import TagItem
-from ..datetime_helpers import parse_datetime
-import copy
+from tableauserverclient.models.revision_item import RevisionItem
+from tableauserverclient.models.tag_item import TagItem
+from tableauserverclient.models.user_item import UserItem
+
+
+class DatasourceItem:
+ """
+ Represents a Tableau datasource item.
+
+ Parameters
+ ----------
+ project_id : Optional[str]
+ The project ID that the datasource belongs to.
+
+ name : Optional[str]
+ The name of the datasource.
+
+ Attributes
+ ----------
+ ask_data_enablement : Optional[str]
+ Determines if a data source allows use of Ask Data. The value can be
+ TSC.DatasourceItem.AskDataEnablement.Enabled,
+ TSC.DatasourceItem.AskDataEnablement.Disabled, or
+ TSC.DatasourceItem.AskDataEnablement.SiteDefault. If no setting is
+ specified, it will default to SiteDefault. See REST API Publish
+ Datasource for more information about ask_data_enablement.
+
+ connected_workbooks_count : Optional[int]
+ The number of workbooks connected to the datasource.
+
+ connections : list[ConnectionItem]
+ The list of data connections (ConnectionItem) for the specified data
+ source. You must first call the populate_connections method to access
+ this data. See the ConnectionItem class.
+
+ content_url : Optional[str]
+ The name of the data source as it would appear in a URL.
+
+ created_at : Optional[datetime.datetime]
+ The time the data source was created.
+
+ certified : Optional[bool]
+ A Boolean value that indicates whether the data source is certified.
+
+ certification_note : Optional[str]
+ The optional note that describes the certified data source.
+
+ datasource_type : Optional[str]
+ The type of data source, for example, sqlserver or excel-direct.
+
+ description : Optional[str]
+ The description for the data source.
+
+ encrypt_extracts : Optional[bool]
+ A Boolean value to determine if a datasource should be encrypted or not.
+ See Extract and Encryption Methods for more information.
+
+ favorites_total : Optional[int]
+ The number of users who have marked the data source as a favorite.
+
+ has_alert : Optional[bool]
+ A Boolean value that indicates whether the data source has an alert.
+
+ has_extracts : Optional[bool]
+ A Boolean value that indicates whether the datasource has extracts.
+
+ id : Optional[str]
+ The identifier for the data source. You need this value to query a
+ specific data source or to delete a data source with the get_by_id and
+ delete methods.
+
+ is_published : Optional[bool]
+ A Boolean value that indicates whether the data source is published.
+
+ name : Optional[str]
+ The name of the data source. If not specified, the name of the published
+ data source file is used.
+
+ owner: Optional[UserItem]
+ The owner of the data source.
+ owner_id : Optional[str]
+ The identifier of the owner of the data source.
+
+ project : Optional[ProjectItem]
+ The project that the data source belongs to.
+
+ project_id : Optional[str]
+ The identifier of the project associated with the data source. You must
+ provide this identifier when you create an instance of a DatasourceItem.
+
+ project_name : Optional[str]
+ The name of the project associated with the data source.
+
+ server_name : Optional[str]
+ The name of the server where the data source is published.
+
+ tags : Optional[set[str]]
+ The tags (list of strings) that have been added to the data source.
+
+ updated_at : Optional[datetime.datetime]
+ The date and time when the data source was last updated.
+
+ use_remote_query_agent : Optional[bool]
+ A Boolean value that indicates whether to allow or disallow your Tableau
+ Cloud site to use Tableau Bridge clients. Bridge allows you to maintain
+ data sources with live connections to supported on-premises data
+ sources. See Configure and Manage the Bridge Client Pool for more
+ information.
+
+ webpage_url : Optional[str]
+ The url of the datasource as displayed in browsers.
+ """
-class DatasourceItem(object):
class AskDataEnablement:
Enabled = "Enabled"
Disabled = "Disabled"
SiteDefault = "SiteDefault"
- def __init__(self, project_id, name=None):
- self._ask_data_enablement = None
- self._certified = None
- self._certification_note = None
- self._connections = None
- self._content_url = 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._updated_at = None
- self._use_remote_query_agent = None
- self._webpage_url = None
- self.description = None
- self.name = name
- self.owner_id = None
- self.project_id = project_id
- self.tags = set()
+ def __repr__(self):
+ return "".format(
+ self._id,
+ self.name,
+ self.description or "No Description",
+ self.project_id,
+ )
+
+ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) -> None:
+ self._ask_data_enablement: Optional[str] = None
+ self._certified: Optional[bool] = None
+ self._certification_note: Optional[str] = None
+ self._connections: Optional[list[ConnectionItem]] = None
+ self._content_url: Optional[str] = None
+ self._created_at: Optional[datetime.datetime] = None
+ self._datasource_type: Optional[str] = None
+ self._description: Optional[str] = None
+ self._encrypt_extracts: Optional[bool] = None
+ self._has_extracts: Optional[bool] = None
+ self._id: Optional[str] = None
+ self._initial_tags: set = set()
+ self._project_name: Optional[str] = None
+ self._revisions = None
+ self._size: Optional[int] = None
+ self._updated_at: Optional[datetime.datetime] = None
+ self._use_remote_query_agent: Optional[bool] = None
+ self._webpage_url: Optional[str] = None
+ self.description: Optional[str] = None
+ self.name: Optional[str] = name
+ self.owner_id: Optional[str] = None
+ self.project_id: Optional[str] = project_id
+ self.tags: set[str] = set()
+ self._connected_workbooks_count: Optional[int] = None
+ self._favorites_total: Optional[int] = None
+ self._has_alert: Optional[bool] = None
+ self._is_published: Optional[bool] = None
+ self._server_name: Optional[str] = None
+ self._project: Optional[ProjectItem] = None
+ self._owner: Optional[UserItem] = None
self._permissions = None
self._data_quality_warnings = None
+ return None
+
@property
- def ask_data_enablement(self):
+ def ask_data_enablement(self) -> Optional[str]:
return self._ask_data_enablement
@ask_data_enablement.setter
@property_is_enum(AskDataEnablement)
- def ask_data_enablement(self, value):
+ def ask_data_enablement(self, value: Optional[str]):
self._ask_data_enablement = value
@property
@@ -59,45 +197,45 @@ def connections(self):
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
- def encrypt_extracts(self):
+ def encrypt_extracts(self) -> Optional[bool]:
return self._encrypt_extracts
@encrypt_extracts.setter
@property_is_boolean
- def encrypt_extracts(self, value):
+ def encrypt_extracts(self, value: Optional[bool]):
self._encrypt_extracts = value
@property
@@ -108,67 +246,108 @@ 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) -> Optional[str]:
return self._project_id
@project_id.setter
- @property_not_nullable
- def project_id(self, value):
+ def project_id(self, value: Optional[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: Optional[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
- def _set_connections(self, connections):
+ @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()
+
+ @property
+ def size(self) -> Optional[int]:
+ return self._size
+
+ @property
+ def connected_workbooks_count(self) -> Optional[int]:
+ return self._connected_workbooks_count
+
+ @property
+ def favorites_total(self) -> Optional[int]:
+ return self._favorites_total
+
+ @property
+ def has_alert(self) -> Optional[bool]:
+ return self._has_alert
+
+ @property
+ def is_published(self) -> Optional[bool]:
+ return self._is_published
+
+ @property
+ def server_name(self) -> Optional[str]:
+ return self._server_name
+
+ @property
+ def project(self) -> Optional[ProjectItem]:
+ return self._project
+
+ @property
+ def owner(self) -> Optional[UserItem]:
+ return self._owner
+
+ def _set_connections(self, connections) -> None:
self._connections = connections
def _set_permissions(self, permissions):
self._permissions = permissions
- def _set_data_quality_warnings(self, dqws):
- self._data_quality_warnings = dqws
+ def _set_data_quality_warnings(self, dqw):
+ self._data_quality_warnings = dqw
+
+ def _set_revisions(self, revisions):
+ self._revisions = revisions
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,
@@ -189,6 +368,14 @@ def _parse_common_elements(self, datasource_xml, ns):
updated_at,
use_remote_query_agent,
webpage_url,
+ size,
+ connected_workbooks_count,
+ favorites_total,
+ has_alert,
+ is_published,
+ server_name,
+ project,
+ owner,
) = self._parse_element(datasource_xml, ns)
self._set_values(
ask_data_enablement,
@@ -209,6 +396,14 @@ def _parse_common_elements(self, datasource_xml, ns):
updated_at,
use_remote_query_agent,
webpage_url,
+ size,
+ connected_workbooks_count,
+ favorites_total,
+ has_alert,
+ is_published,
+ server_name,
+ project,
+ owner,
)
return self
@@ -232,6 +427,14 @@ def _set_values(
updated_at,
use_remote_query_agent,
webpage_url,
+ size,
+ connected_workbooks_count,
+ favorites_total,
+ has_alert,
+ is_published,
+ server_name,
+ project,
+ owner,
):
if ask_data_enablement is not None:
self._ask_data_enablement = ask_data_enablement
@@ -269,82 +472,61 @@ def _set_values(
self._use_remote_query_agent = str(use_remote_query_agent).lower() == "true"
if webpage_url:
self._webpage_url = webpage_url
+ if size is not None:
+ self._size = int(size)
+ if connected_workbooks_count is not None:
+ self._connected_workbooks_count = connected_workbooks_count
+ if favorites_total is not None:
+ self._favorites_total = favorites_total
+ if has_alert is not None:
+ self._has_alert = has_alert
+ if is_published is not None:
+ self._is_published = is_published
+ if server_name is not None:
+ self._server_name = server_name
+ if project is not None:
+ self._project = project
+ if owner is not None:
+ self._owner = owner
@classmethod
- def from_response(cls, resp, 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:
- (
- ask_data_enablement,
- certified,
- certification_note,
- content_url,
- created_at,
- datasource_type,
- description,
- encrypt_extracts,
- has_extracts,
- id_,
- name,
- owner_id,
- project_id,
- project_name,
- tags,
- updated_at,
- use_remote_query_agent,
- webpage_url,
- ) = cls._parse_element(datasource_xml, ns)
- datasource_item = cls(project_id)
- datasource_item._set_values(
- ask_data_enablement,
- certified,
- certification_note,
- content_url,
- created_at,
- datasource_type,
- description,
- encrypt_extracts,
- has_extracts,
- id_,
- name,
- owner_id,
- None,
- project_name,
- tags,
- updated_at,
- use_remote_query_agent,
- webpage_url,
- )
+ datasource_item = cls.from_xml(datasource_xml, ns)
all_datasource_items.append(datasource_item)
return all_datasource_items
+ @classmethod
+ def from_xml(cls, datasource_xml, ns):
+ datasource_item = cls()
+ datasource_item._set_values(*cls._parse_element(datasource_xml, ns))
+ return datasource_item
+
@staticmethod
- def _parse_element(datasource_xml, 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'
- certification_note = datasource_xml.get("certificationNote", None)
- certified = str(datasource_xml.get("isCertified", None)).lower() == "true"
- content_url = datasource_xml.get("contentUrl", None)
- created_at = parse_datetime(datasource_xml.get("createdAt", None))
+ 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"
encrypt_extracts = datasource_xml.get("encryptExtracts", None)
has_extracts = datasource_xml.get("hasExtracts", None)
- id_ = datasource_xml.get("id", None)
- name = datasource_xml.get("name", None)
- updated_at = parse_datetime(datasource_xml.get("updatedAt", None))
use_remote_query_agent = datasource_xml.get("useRemoteQueryAgent", None)
webpage_url = datasource_xml.get("webpageUrl", None)
+ size = datasource_xml.get("size", None)
+ connected_workbooks_count = nullable_str_to_int(datasource_xml.get("connectedWorkbooksCount", None))
+ favorites_total = nullable_str_to_int(datasource_xml.get("favoritesTotal", None))
+ has_alert = nullable_str_to_bool(datasource_xml.get("hasAlert", None))
+ is_published = nullable_str_to_bool(datasource_xml.get("isPublished", None))
+ server_name = datasource_xml.get("serverName", None)
tags = None
tags_elem = datasource_xml.find(".//t:tags", namespaces=ns)
@@ -355,12 +537,14 @@ def _parse_element(datasource_xml, ns):
project_name = None
project_elem = datasource_xml.find(".//t:project", namespaces=ns)
if project_elem is not None:
+ project = ProjectItem.from_xml(project_elem, ns)
project_id = project_elem.get("id", None)
project_name = project_elem.get("name", None)
owner_id = None
owner_elem = datasource_xml.find(".//t:owner", namespaces=ns)
if owner_elem is not None:
+ owner = UserItem.from_xml(owner_elem, ns)
owner_id = owner_elem.get("id", None)
ask_data_enablement = None
@@ -387,4 +571,12 @@ def _parse_element(datasource_xml, ns):
updated_at,
use_remote_query_agent,
webpage_url,
+ size,
+ connected_workbooks_count,
+ favorites_total,
+ has_alert,
+ is_published,
+ server_name,
+ project,
+ owner,
)
diff --git a/tableauserverclient/models/dqw_item.py b/tableauserverclient/models/dqw_item.py
index a7f8ec9cb..fbda9d9f2 100644
--- a/tableauserverclient/models/dqw_item.py
+++ b/tableauserverclient/models/dqw_item.py
@@ -1,8 +1,9 @@
-import xml.etree.ElementTree as ET
-from ..datetime_helpers import parse_datetime
+from defusedxml.ElementTree import fromstring
+from tableauserverclient.datetime_helpers import parse_datetime
-class DQWItem(object):
+
+class DQWItem:
class WarningType:
WARNING = "WARNING"
DEPRECATED = "DEPRECATED"
@@ -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/extract_item.py b/tableauserverclient/models/extract_item.py
new file mode 100644
index 000000000..7562ffdde
--- /dev/null
+++ b/tableauserverclient/models/extract_item.py
@@ -0,0 +1,82 @@
+from typing import Optional, List
+from defusedxml.ElementTree import fromstring
+import xml.etree.ElementTree as ET
+
+
+class ExtractItem:
+ """
+ An extract refresh task item.
+
+ Attributes
+ ----------
+ id : str
+ The ID of the extract refresh task
+ priority : int
+ The priority of the task
+ type : str
+ The type of extract refresh (incremental or full)
+ workbook_id : str, optional
+ The ID of the workbook if this is a workbook extract
+ datasource_id : str, optional
+ The ID of the datasource if this is a datasource extract
+ """
+
+ def __init__(
+ self, priority: int, refresh_type: str, workbook_id: Optional[str] = None, datasource_id: Optional[str] = None
+ ):
+ self._id: Optional[str] = None
+ self._priority = priority
+ self._type = refresh_type
+ self._workbook_id = workbook_id
+ self._datasource_id = datasource_id
+
+ @property
+ def id(self) -> Optional[str]:
+ return self._id
+
+ @property
+ def priority(self) -> int:
+ return self._priority
+
+ @property
+ def type(self) -> str:
+ return self._type
+
+ @property
+ def workbook_id(self) -> Optional[str]:
+ return self._workbook_id
+
+ @property
+ def datasource_id(self) -> Optional[str]:
+ return self._datasource_id
+
+ @classmethod
+ def from_response(cls, resp: str, ns: dict) -> List["ExtractItem"]:
+ """Create ExtractItem objects from XML response."""
+ parsed_response = fromstring(resp)
+ return cls.from_xml_element(parsed_response, ns)
+
+ @classmethod
+ def from_xml_element(cls, parsed_response: ET.Element, ns: dict) -> List["ExtractItem"]:
+ """Create ExtractItem objects from XML element."""
+ all_extract_items = []
+ all_extract_xml = parsed_response.findall(".//t:extract", namespaces=ns)
+
+ for extract_xml in all_extract_xml:
+ extract_id = extract_xml.get("id", None)
+ priority = int(extract_xml.get("priority", 0))
+ refresh_type = extract_xml.get("type", "")
+
+ # Check for workbook or datasource
+ workbook_elem = extract_xml.find(".//t:workbook", namespaces=ns)
+ datasource_elem = extract_xml.find(".//t:datasource", namespaces=ns)
+
+ workbook_id = workbook_elem.get("id", None) if workbook_elem is not None else None
+ datasource_id = datasource_elem.get("id", None) if datasource_elem is not None else None
+
+ extract_item = cls(priority, refresh_type, workbook_id, datasource_id)
+ extract_item._id = extract_id
+
+ all_extract_items.append(extract_item)
+
+ return all_extract_items
diff --git a/tableauserverclient/models/favorites_item.py b/tableauserverclient/models/favorites_item.py
index 3d6feff5d..4fea280f7 100644
--- a/tableauserverclient/models/favorites_item.py
+++ b/tableauserverclient/models/favorites_item.py
@@ -1,49 +1,89 @@
-import xml.etree.ElementTree as ET
import logging
-from .workbook_item import WorkbookItem
-from .view_item import ViewItem
-from .project_item import ProjectItem
-from .datasource_item import DatasourceItem
-logger = logging.getLogger("tableau.models.favorites_item")
+from typing import Union
+from defusedxml.ElementTree import fromstring
+from tableauserverclient.models.tableau_types import TableauItem
+from tableauserverclient.models.datasource_item import DatasourceItem
+from tableauserverclient.models.flow_item import FlowItem
+from tableauserverclient.models.project_item import ProjectItem
+from tableauserverclient.models.metric_item import MetricItem
+from tableauserverclient.models.view_item import ViewItem
+from tableauserverclient.models.workbook_item import WorkbookItem
-class FavoriteItem:
- class Type:
- Workbook = "workbook"
- Datasource = "datasource"
- View = "view"
- Project = "project"
+from tableauserverclient.helpers.logging import logger
+
+FavoriteType = dict[
+ str,
+ list[TableauItem],
+]
+
+class FavoriteItem:
@classmethod
- def from_response(cls, xml, namespace):
- favorites = {
+ def from_response(cls, xml: Union[str, bytes], namespace: dict) -> FavoriteType:
+ favorites: FavoriteType = {
"datasources": [],
+ "flows": [],
"projects": [],
+ "metrics": [],
"views": [],
"workbooks": [],
}
+ parsed_response = fromstring(xml)
- parsed_response = ET.fromstring(xml)
- for workbook in parsed_response.findall(".//t:favorite/t:workbook", namespace):
- fav_workbook = WorkbookItem("")
- fav_workbook._set_values(*fav_workbook._parse_element(workbook, namespace))
- if fav_workbook:
- favorites["workbooks"].append(fav_workbook)
- for view in parsed_response.findall(".//t:favorite[t:view]", namespace):
- fav_views = ViewItem.from_xml_element(view, namespace)
- if fav_views:
- for fav_view in fav_views:
- favorites["views"].append(fav_view)
- for datasource in parsed_response.findall(".//t:favorite/t:datasource", namespace):
- fav_datasource = DatasourceItem("")
- fav_datasource._set_values(*fav_datasource._parse_element(datasource, namespace))
+ datasources_xml = parsed_response.findall(".//t:favorite/t:datasource", namespace)
+ flows_xml = parsed_response.findall(".//t:favorite/t:flow", namespace)
+ metrics_xml = parsed_response.findall(".//t:favorite/t:metric", namespace)
+ projects_xml = parsed_response.findall(".//t:favorite/t:project", namespace)
+ views_xml = parsed_response.findall(".//t:favorite/t:view", namespace)
+ workbooks_xml = parsed_response.findall(".//t:favorite/t:workbook", namespace)
+
+ logger.debug(
+ "ds: {}, flows: {}, metrics: {}, projects: {}, views: {}, wbs: {}".format(
+ len(datasources_xml),
+ len(flows_xml),
+ len(metrics_xml),
+ len(projects_xml),
+ len(views_xml),
+ len(workbooks_xml),
+ )
+ )
+ for datasource in datasources_xml:
+ fav_datasource = DatasourceItem.from_xml(datasource, namespace)
if fav_datasource:
+ logger.debug(fav_datasource)
favorites["datasources"].append(fav_datasource)
- for project in parsed_response.findall(".//t:favorite/t:project", namespace):
- fav_project = ProjectItem("p")
- fav_project._set_values(*fav_project._parse_element(project))
+
+ for flow in flows_xml:
+ fav_flow = FlowItem.from_xml(flow, namespace)
+ if fav_flow:
+ logger.debug(fav_flow)
+ favorites["flows"].append(fav_flow)
+
+ for metric in metrics_xml:
+ fav_metric = MetricItem.from_xml(metric, namespace)
+ if fav_metric:
+ logger.debug(fav_metric)
+ favorites["metrics"].append(fav_metric)
+
+ for project in projects_xml:
+ fav_project = ProjectItem.from_xml(project, namespace)
if fav_project:
+ logger.debug(fav_project)
favorites["projects"].append(fav_project)
+ for view in views_xml:
+ fav_view = ViewItem.from_xml(view, namespace)
+ if fav_view:
+ logger.debug(fav_view)
+ favorites["views"].append(fav_view)
+
+ for workbook in workbooks_xml:
+ fav_workbook = WorkbookItem.from_xml(workbook, namespace)
+ if fav_workbook:
+ logger.debug(fav_workbook)
+ favorites["workbooks"].append(fav_workbook)
+
+ logger.debug(favorites)
return favorites
diff --git a/tableauserverclient/models/fileupload_item.py b/tableauserverclient/models/fileupload_item.py
index a697a5aaf..aea4dfe1f 100644
--- a/tableauserverclient/models/fileupload_item.py
+++ b/tableauserverclient/models/fileupload_item.py
@@ -1,7 +1,7 @@
-import xml.etree.ElementTree as ET
+from defusedxml.ElementTree import fromstring
-class FileuploadItem(object):
+class FileuploadItem:
def __init__(self):
self._file_size = None
self._upload_session_id = None
@@ -11,12 +11,12 @@ def upload_session_id(self):
return self._upload_session_id
@property
- def file_size(self):
- return self._file_size
+ def file_size(self) -> int:
+ return int(self._file_size)
@classmethod
def from_response(cls, resp, ns):
- 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..063897e41 100644
--- a/tableauserverclient/models/flow_item.py
+++ b/tableauserverclient/models/flow_item.py
@@ -1,28 +1,86 @@
-import xml.etree.ElementTree as ET
-from .exceptions import UnpopulatedPropertyError
-from .property_decorators import property_not_nullable
-from .tag_item import TagItem
-from ..datetime_helpers import parse_datetime
import copy
+import datetime
+import xml.etree.ElementTree as ET
+from typing import Iterable, Optional
+
+from defusedxml.ElementTree import fromstring
+
+from tableauserverclient.datetime_helpers import parse_datetime
+from tableauserverclient.models.connection_item import ConnectionItem
+from tableauserverclient.models.dqw_item import DQWItem
+from tableauserverclient.models.exceptions import UnpopulatedPropertyError
+from tableauserverclient.models.permissions_item import Permission
+from tableauserverclient.models.property_decorators import property_not_nullable
+from tableauserverclient.models.tag_item import TagItem
+
+
+class FlowItem:
+ """
+ Represents a Tableau Flow item.
+
+ Parameters
+ ----------
+ project_id: str
+ The ID of the project that the flow belongs to.
+
+ name: Optional[str]
+ The name of the flow.
+
+ Attributes
+ ----------
+ connections: Iterable[ConnectionItem]
+ The connections associated with the flow. This property is not populated
+ by default and must be populated by calling the `populate_connections`
+ method.
+
+ created_at: Optional[datetime.datetime]
+ The date and time when the flow was created.
+
+ description: Optional[str]
+ The description of the flow.
+
+ dqws: Iterable[DQWItem]
+ The data quality warnings associated with the flow. This property is not
+ populated by default and must be populated by calling the `populate_dqws`
+ method.
+
+ id: Optional[str]
+ The ID of the flow.
+
+ name: Optional[str]
+ The name of the flow.
+ owner_id: Optional[str]
+ The ID of the user who owns the flow.
-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
-
- self._connections = None
- self._permissions = None
- self._data_quality_warnings = None
+ project_name: Optional[str]
+ The name of the project that the flow belongs to.
+
+ tags: set[str]
+ The tags associated with the flow.
+ """
+
+ def __repr__(self):
+ return " None:
+ self._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: Optional[Iterable[ConnectionItem]] = None
+ self._permissions: Optional[Iterable[Permission]] = None
+ self._data_quality_warnings: Optional[Iterable[DQWItem]] = None
@property
def connections(self):
@@ -39,11 +97,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 +112,32 @@ 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):
- 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):
@@ -92,12 +146,12 @@ def _set_connections(self, connections):
def _set_permissions(self, permissions):
self._permissions = permissions
- def _set_data_quality_warnings(self, dqws):
- self._data_quality_warnings = dqws
+ def _set_data_quality_warnings(self, dqw):
+ self._data_quality_warnings = dqw
def _parse_common_elements(self, flow_xml, ns):
if not isinstance(flow_xml, ET.Element):
- 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,40 +215,45 @@ 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:
- (
- id_,
- name,
- description,
- webpage_url,
- created_at,
- updated_at,
- tags,
- project_id,
- project_name,
- owner_id,
- ) = cls._parse_element(flow_xml, ns)
- flow_item = cls(project_id)
- flow_item._set_values(
- id_,
- name,
- description,
- webpage_url,
- created_at,
- updated_at,
- tags,
- None,
- project_name,
- owner_id,
- )
+ flow_item = cls.from_xml(flow_xml, ns)
all_flow_items.append(flow_item)
return all_flow_items
+ @classmethod
+ def from_xml(cls, flow_xml, ns) -> "FlowItem":
+ (
+ id_,
+ name,
+ description,
+ webpage_url,
+ created_at,
+ updated_at,
+ tags,
+ project_id,
+ project_name,
+ owner_id,
+ ) = cls._parse_element(flow_xml, ns)
+ flow_item = cls(project_id)
+ flow_item._set_values(
+ id_,
+ name,
+ description,
+ webpage_url,
+ created_at,
+ updated_at,
+ tags,
+ None,
+ project_name,
+ owner_id,
+ )
+ return flow_item
+
@staticmethod
def _parse_element(flow_xml, ns):
id_ = flow_xml.get("id", None)
diff --git a/tableauserverclient/models/flow_run_item.py b/tableauserverclient/models/flow_run_item.py
index 251c667b1..f2f1d561f 100644
--- a/tableauserverclient/models/flow_run_item.py
+++ b/tableauserverclient/models/flow_run_item.py
@@ -1,54 +1,50 @@
-import xml.etree.ElementTree as ET
-from ..datetime_helpers import parse_datetime
import itertools
+from datetime import datetime
+from typing import Optional
+from defusedxml.ElementTree import fromstring
-class FlowRunItem(object):
+from tableauserverclient.datetime_helpers import parse_datetime
+
+
+class FlowRunItem:
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 +70,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 +86,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..00f35e518 100644
--- a/tableauserverclient/models/group_item.py
+++ b/tableauserverclient/models/group_item.py
@@ -1,88 +1,143 @@
-import xml.etree.ElementTree as ET
+from typing import Callable, Optional, TYPE_CHECKING
+
+from defusedxml.ElementTree import fromstring
+
from .exceptions import UnpopulatedPropertyError
from .property_decorators import property_not_empty, property_is_enum
from .reference_item import ResourceReference
from .user_item import UserItem
+if TYPE_CHECKING:
+ from tableauserverclient.server import Pager
+
+
+class GroupItem:
+ """
+ The GroupItem class contains the attributes for the group resources on
+ Tableau Server. The GroupItem class defines the information you can request
+ or query from Tableau Server. The class members correspond to the attributes
+ of a server request or response payload.
+
+ Parameters
+ ----------
+ name: str
+ The name of the group.
+
+ domain_name: str
+ The name of the Active Directory domain ("local" if local authentication is used).
+
+ Properties
+ ----------
+ users: Pager[UserItem]
+ The users in the group. Must be populated with a call to `populate_users()`.
+
+ id: str
+ The unique identifier for the group.
+
+ minimum_site_role: str
+ The minimum site role for users in the group. Use the `UserItem.Roles` enum.
+ Users in the group cannot have their site role set lower than this value.
-class GroupItem(object):
+ license_mode: str
+ The mode defining when to apply licenses for group members. When the
+ mode is onLogin, a license is granted for each group member when they
+ login to a site. When the mode is onSync, a license is granted for group
+ members each time the domain is synced.
- tag_name = "group"
+ Attributes
+ ----------
+ user_count: Optional[int]
+ The number of users in the group.
+
+ Examples
+ --------
+ >>> # Create a new group item
+ >>> newgroup = TSC.GroupItem('My 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
+ self._user_count: Optional[int] = None
+
+ def __repr__(self):
+ return f"{self.__class__.__name__}({self.__dict__!r})"
@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):
- 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
+ @property
+ def user_count(self) -> Optional[int]:
+ return self._user_count
+
@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)
group_item = cls(name)
group_item._id = group_xml.get("id", None)
+ group_item._user_count = int(count) if (count := group_xml.get("userCount", None)) else None
# Domain name is returned in a domain element for some calls
domain_elem = group_xml.find(".//t:domain", namespaces=ns)
@@ -100,5 +155,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/groupset_item.py b/tableauserverclient/models/groupset_item.py
new file mode 100644
index 000000000..aa653a79e
--- /dev/null
+++ b/tableauserverclient/models/groupset_item.py
@@ -0,0 +1,53 @@
+from typing import Optional
+import xml.etree.ElementTree as ET
+
+from defusedxml.ElementTree import fromstring
+
+from tableauserverclient.models.group_item import GroupItem
+from tableauserverclient.models.reference_item import ResourceReference
+
+
+class GroupSetItem:
+ tag_name: str = "groupSet"
+
+ def __init__(self, name: Optional[str] = None) -> None:
+ self.name = name
+ self.id: Optional[str] = None
+ self.groups: list["GroupItem"] = []
+ self.group_count: int = 0
+
+ def __str__(self) -> str:
+ name = self.name
+ id = self.id
+ return f"<{self.__class__.__qualname__}({name=}, {id=})>"
+
+ def __repr__(self) -> str:
+ return self.__str__()
+
+ @classmethod
+ def from_response(cls, response: bytes, ns: dict[str, str]) -> list["GroupSetItem"]:
+ parsed_response = fromstring(response)
+ all_groupset_xml = parsed_response.findall(".//t:groupSet", namespaces=ns)
+ return [cls.from_xml(xml, ns) for xml in all_groupset_xml]
+
+ @classmethod
+ def from_xml(cls, groupset_xml: ET.Element, ns: dict[str, str]) -> "GroupSetItem":
+ def get_group(group_xml: ET.Element) -> GroupItem:
+ group_item = GroupItem()
+ group_item._id = group_xml.get("id")
+ group_item.name = group_xml.get("name")
+ return group_item
+
+ group_set_item = cls()
+ group_set_item.name = groupset_xml.get("name")
+ group_set_item.id = groupset_xml.get("id")
+ group_set_item.group_count = int(count) if (count := groupset_xml.get("groupCount")) else 0
+ group_set_item.groups = [
+ get_group(group_xml) for group_xml in groupset_xml.findall(".//t:group", namespaces=ns)
+ ]
+
+ return group_set_item
+
+ @staticmethod
+ def as_reference(id_: str) -> ResourceReference:
+ return ResourceReference(id_, GroupSetItem.tag_name)
diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py
index 320e01ef2..52fd658c5 100644
--- a/tableauserverclient/models/interval_item.py
+++ b/tableauserverclient/models/interval_item.py
@@ -1,7 +1,14 @@
from .property_decorators import property_is_valid_time, property_not_nullable
-class IntervalItem(object):
+class IntervalItem:
+ """
+ This class sets the frequency and start time of the scheduled item. This
+ class contains the classes for the hourly, daily, weekly, and monthly
+ intervals. This class mirrors the options you can set using the REST API and
+ the Tableau Server interface.
+ """
+
class Frequency:
Hourly = "Hourly"
Daily = "Daily"
@@ -25,12 +32,32 @@ class Day:
LastDay = "LastDay"
-class HourlyInterval(object):
- def __init__(self, start_time, end_time, interval_value):
+class HourlyInterval:
+ """
+ Runs scheduled item hourly. To set the hourly interval, you create an
+ instance of the HourlyInterval class and assign the following values:
+ start_time, end_time, and interval_value. To set the start_time and
+ end_time, assign the time value using this syntax: start_time=time(hour, minute)
+ and end_time=time(hour, minute). The hour is specified in 24 hour time.
+ The interval_value specifies how often the to run the task within the
+ start and end time. The options are expressed in hours. For example,
+ interval_value=.25 is every 15 minutes. The values are .25, .5, 1, 2, 4, 6,
+ 8, 12. Hourly schedules that run more frequently than every 60 minutes must
+ have start and end times that are on the hour.
+ """
+ def __init__(self, start_time, end_time, interval_value):
self.start_time = start_time
self.end_time = end_time
- self.interval = interval_value
+
+ # interval should be a tuple, if it is not, assign as a tuple with single value
+ if isinstance(interval_value, tuple):
+ self.interval = interval_value
+ else:
+ self.interval = (interval_value,)
+
+ def __repr__(self):
+ return f"<{self.__class__.__name__} start={self.start_time} end={self.end_time} interval={self.interval}>"
@property
def _frequency(self):
@@ -61,31 +88,59 @@ def interval(self):
return self._interval
@interval.setter
- def interval(self, interval):
- VALID_INTERVALS = {0.25, 0.5, 1, 2, 4, 6, 8, 12}
- if float(interval) not in VALID_INTERVALS:
- error = "Invalid interval {} not in {}".format(interval, str(VALID_INTERVALS))
- raise ValueError(error)
+ def interval(self, intervals):
+ VALID_INTERVALS = {0.25, 0.5, 1, 2, 4, 6, 8, 12, 24}
+ for interval in intervals:
+ # if an hourly interval is a string, then it is a weekDay interval
+ if isinstance(interval, str) and not interval.isnumeric() and not hasattr(IntervalItem.Day, interval):
+ error = f"Invalid weekDay interval {interval}"
+ raise ValueError(error)
+
+ # if an hourly interval is a number, it is an hours or minutes interval
+ if isinstance(interval, (int, float)) and float(interval) not in VALID_INTERVALS:
+ error = f"Invalid interval {interval} not in {str(VALID_INTERVALS)}"
+ raise ValueError(error)
- self._interval = interval
+ self._interval = intervals
def _interval_type_pairs(self):
+ interval_type_pairs = []
+ for interval in self.interval:
+ # We use fractional hours for the two minute-based intervals.
+ # Need to convert to minutes from hours here
+ if interval in {0.25, 0.5}:
+ calculated_interval = int(interval * 60)
+ interval_type = IntervalItem.Occurrence.Minutes
- # We use fractional hours for the two minute-based intervals.
- # Need to convert to minutes from hours here
- if self.interval in {0.25, 0.5}:
- calculated_interval = int(self.interval * 60)
- interval_type = IntervalItem.Occurrence.Minutes
- else:
- calculated_interval = self.interval
- interval_type = IntervalItem.Occurrence.Hours
+ interval_type_pairs.append((interval_type, str(calculated_interval)))
+ else:
+ # if the interval is a non-numeric string, it will always be a weekDay
+ if isinstance(interval, str) and not interval.isnumeric():
+ interval_type = IntervalItem.Occurrence.WeekDay
+
+ interval_type_pairs.append((interval_type, str(interval)))
+ # otherwise the interval is hours
+ else:
+ interval_type = IntervalItem.Occurrence.Hours
- return [(interval_type, str(calculated_interval))]
+ interval_type_pairs.append((interval_type, str(interval)))
+ return interval_type_pairs
-class DailyInterval(object):
- def __init__(self, start_time):
+
+class DailyInterval:
+ """
+ Runs the scheduled item daily. To set the daily interval, you create an
+ instance of the DailyInterval and assign the start_time. The start time uses
+ the syntax start_time=time(hour, minute).
+ """
+
+ def __init__(self, start_time, *interval_values):
self.start_time = start_time
+ self.interval = interval_values
+
+ def __repr__(self):
+ return f"<{self.__class__.__name__} start={self.start_time} interval={self.interval}>"
@property
def _frequency(self):
@@ -101,12 +156,69 @@ 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, intervals):
+ VALID_INTERVALS = {0.25, 0.5, 1, 2, 4, 6, 8, 12, 24}
+
+ for interval in intervals:
+ # if an hourly interval is a string, then it is a weekDay interval
+ if isinstance(interval, str) and not interval.isnumeric() and not hasattr(IntervalItem.Day, interval):
+ error = f"Invalid weekDay interval {interval}"
+ raise ValueError(error)
+
+ # if an hourly interval is a number, it is an hours or minutes interval
+ if isinstance(interval, (int, float)) and float(interval) not in VALID_INTERVALS:
+ error = f"Invalid interval {interval} not in {str(VALID_INTERVALS)}"
+ raise ValueError(error)
+
+ self._interval = intervals
+
+ def _interval_type_pairs(self):
+ interval_type_pairs = []
+ for interval in self.interval:
+ # We use fractional hours for the two minute-based intervals.
+ # Need to convert to minutes from hours here
+ if interval in {0.25, 0.5}:
+ calculated_interval = int(interval * 60)
+ interval_type = IntervalItem.Occurrence.Minutes
+
+ interval_type_pairs.append((interval_type, str(calculated_interval)))
+ else:
+ # if the interval is a non-numeric string, it will always be a weekDay
+ if isinstance(interval, str) and not interval.isnumeric():
+ interval_type = IntervalItem.Occurrence.WeekDay
+
+ interval_type_pairs.append((interval_type, str(interval)))
+ # otherwise the interval is hours
+ else:
+ interval_type = IntervalItem.Occurrence.Hours
+
+ interval_type_pairs.append((interval_type, str(interval)))
+
+ return interval_type_pairs
+
+
+class WeeklyInterval:
+ """
+ Runs the scheduled item once a week. To set the weekly interval, you create
+ an instance of the WeeklyInterval and assign the start time and multiple
+ instances for the interval_value (days of week and start time). The start
+ time uses the syntax time(hour, minute). The interval_value is the day of
+ the week, expressed as a IntervalItem. For example
+ TSC.IntervalItem.Day.Monday for Monday.
+ """
-class WeeklyInterval(object):
def __init__(self, start_time, *interval_values):
self.start_time = start_time
self.interval = interval_values
+ def __repr__(self):
+ return f"<{self.__class__.__name__} start={self.start_time} interval={self.interval}>"
+
@property
def _frequency(self):
return IntervalItem.Frequency.Weekly
@@ -136,10 +248,23 @@ def _interval_type_pairs(self):
return [(IntervalItem.Occurrence.WeekDay, day) for day in self.interval]
-class MonthlyInterval(object):
+class MonthlyInterval:
+ """
+ Runs the scheduled item once a month. To set the monthly interval, you
+ create an instance of the MonthlyInterval and assign the start time and day.
+ """
+
def __init__(self, start_time, interval_value):
self.start_time = start_time
- self.interval = str(interval_value)
+
+ # interval should be a tuple, if it is not, assign as a tuple with single value
+ if isinstance(interval_value, tuple):
+ self.interval = interval_value
+ else:
+ self.interval = (interval_value,)
+
+ def __repr__(self):
+ return f"<{self.__class__.__name__} start={self.start_time} interval={self.interval}>"
@property
def _frequency(self):
@@ -160,24 +285,37 @@ def interval(self):
return self._interval
@interval.setter
- def interval(self, interval_value):
- error = "Invalid interval value for a monthly frequency: {}.".format(interval_value)
-
- # This is weird because the value could be a str or an int
- # The only valid str is 'LastDay' so we check that first. If that's not it
- # try to convert it to an int, if that fails because it's an incorrect string
- # like 'badstring' we catch and re-raise. Otherwise we convert to int and check
- # that it's in range 1-31
-
- if interval_value != "LastDay":
- try:
- if not (1 <= int(interval_value) <= 31):
- raise ValueError(error)
- except ValueError:
- if interval_value != "LastDay":
- raise ValueError(error)
+ def interval(self, interval_values):
+ # Valid monthly intervals strings can contain any of the following
+ # day numbers (1-31) (integer or string)
+ # relative day within the month (First, Second, ... Last)
+ # week days (Sunday, Monday, ... LastDay)
+ VALID_INTERVALS = [
+ "Sunday",
+ "Monday",
+ "Tuesday",
+ "Wednesday",
+ "Thursday",
+ "Friday",
+ "Saturday",
+ "LastDay",
+ "First",
+ "Second",
+ "Third",
+ "Fourth",
+ "Fifth",
+ "Last",
+ ]
+ for value in range(1, 32):
+ VALID_INTERVALS.append(str(value))
+ VALID_INTERVALS.append(value)
+
+ for interval_value in interval_values:
+ if interval_value not in VALID_INTERVALS:
+ error = f"Invalid monthly interval: {interval_value}"
+ raise ValueError(error)
- self._interval = str(interval_value)
+ self._interval = interval_values
def _interval_type_pairs(self):
- return [(IntervalItem.Occurrence.MonthDay, self.interval)]
+ return [(IntervalItem.Occurrence.MonthDay, str(day)) for day in self.interval]
diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py
index 8c21b24e6..d650eb846 100644
--- a/tableauserverclient/models/job_item.py
+++ b/tableauserverclient/models/job_item.py
@@ -1,31 +1,106 @@
-import xml.etree.ElementTree as ET
-from .flow_run_item import FlowRunItem
-from ..datetime_helpers import parse_datetime
+import datetime
+from typing import Optional
+from defusedxml.ElementTree import fromstring
+
+from tableauserverclient.datetime_helpers import parse_datetime
+from tableauserverclient.models.flow_run_item import FlowRunItem
+
+
+class JobItem:
+ """
+ Using the TSC library, you can get information about an asynchronous process
+ (or job) on the server. These jobs can be created when Tableau runs certain
+ tasks that could be long running, such as importing or synchronizing users
+ from Active Directory, or running an extract refresh. For example, the REST
+ API methods to create or update groups, to run an extract refresh task, or
+ to publish workbooks can take an asJob parameter (asJob-true) that creates a
+ background process (the job) to complete the call. Information about the
+ asynchronous job is returned from the method.
+
+ If you have the identifier of the job, you can use the TSC library to find
+ out the status of the asynchronous job.
+
+ The job properties are defined in the JobItem class. The class corresponds
+ to the properties for jobs you can access using the Tableau Server REST API.
+ The job methods are based upon the endpoints for jobs in the REST API and
+ operate on the JobItem class.
+
+ Parameters
+ ----------
+ id_ : str
+ The identifier of the job.
+
+ job_type : str
+ The type of job.
+
+ progress : str
+ The progress of the job.
+
+ created_at : datetime.datetime
+ The date and time the job was created.
+
+ started_at : Optional[datetime.datetime]
+ The date and time the job was started.
+
+ completed_at : Optional[datetime.datetime]
+ The date and time the job was completed.
+
+ finish_code : int
+ The finish code of the job. 0 for success, 1 for failure, 2 for cancelled.
+
+ notes : Optional[list[str]]
+ Contains detailed notes about the job.
+
+ mode : Optional[str]
+
+ workbook_id : Optional[str]
+ The identifier of the workbook associated with the job.
+
+ datasource_id : Optional[str]
+ The identifier of the datasource associated with the job.
+
+ flow_run : Optional[FlowRunItem]
+ The flow run associated with the job.
+
+ updated_at : Optional[datetime.datetime]
+ The date and time the job was last updated.
+
+ workbook_name : Optional[str]
+ The name of the workbook associated with the job.
+
+ datasource_name : Optional[str]
+ The name of the datasource associated with the job.
+ """
-class JobItem(object):
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
+ Completed: int = 3
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,
+ workbook_id: Optional[str] = None,
+ datasource_id: Optional[str] = None,
+ flow_run: Optional[FlowRunItem] = None,
+ updated_at: Optional[datetime.datetime] = None,
+ workbook_name: Optional[str] = None,
+ datasource_name: Optional[str] = None,
):
self._id = id_
self._type = job_type
@@ -34,51 +109,72 @@ 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._workbook_id = workbook_id
+ self._datasource_id = datasource_id
self._flow_run = flow_run
+ self._updated_at = updated_at
+ self._workbook_name = workbook_name
+ self._datasource_name = datasource_name
@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
+ @property
+ def workbook_id(self) -> Optional[str]:
+ return self._workbook_id
+
+ @workbook_id.setter
+ def workbook_id(self, value: Optional[str]) -> None:
+ self._workbook_id = value
+
+ @property
+ def datasource_id(self) -> Optional[str]:
+ return self._datasource_id
+
+ @datasource_id.setter
+ def datasource_id(self, value: Optional[str]) -> None:
+ self._datasource_id = value
+
@property
def flow_run(self):
return self._flow_run
@@ -87,15 +183,30 @@ def flow_run(self):
def flow_run(self, value):
self._flow_run = value
- def __repr__(self):
+ @property
+ def updated_at(self) -> Optional[datetime.datetime]:
+ return self._updated_at
+
+ @property
+ def workbook_name(self) -> Optional[str]:
+ return self._workbook_name
+
+ @property
+ def datasource_name(self) -> Optional[str]:
+ return self._datasource_name
+
+ def __str__(self):
return (
- "".format(**self.__dict__)
)
+ def __repr__(self):
+ return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}"
+
@classmethod
- def from_response(cls, xml, ns):
- 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]
@@ -113,7 +224,14 @@ def _parse_element(cls, element, ns):
finish_code = int(element.get("finishCode", -1))
notes = [note.text for note in element.findall(".//t:notes", namespaces=ns)] or None
mode = element.get("mode", None)
+ workbook = element.find(".//t:workbook[@id]", namespaces=ns)
+ workbook_id = workbook.get("id") if workbook is not None else None
+ workbook_name = workbook.get("name") if workbook is not None else None
+ datasource = element.find(".//t:datasource[@id]", namespaces=ns)
+ datasource_id = datasource.get("id") if datasource is not None else None
+ datasource_name = datasource.get("name") if datasource is not None else None
flow_run = None
+ updated_at = parse_datetime(element.get("updatedAt", None))
for flow_job in element.findall(".//t:runFlowJobType", namespaces=ns):
flow_run = FlowRunItem()
flow_run._id = flow_job.get("flowRunId", None)
@@ -130,29 +248,34 @@ def _parse_element(cls, element, ns):
finish_code,
notes,
mode,
+ workbook_id,
+ datasource_id,
flow_run,
+ updated_at,
+ workbook_name,
+ datasource_name,
)
-class BackgroundJobItem(object):
+class BackgroundJobItem:
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
@@ -164,51 +287,57 @@ def __init__(
self._title = title
self._subtitle = subtitle
+ def __str__(self):
+ return f"<{self.__class__.__qualname__} {self._id} {self._type}>"
+
+ def __repr__(self):
+ return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}"
+
@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/linked_tasks_item.py b/tableauserverclient/models/linked_tasks_item.py
new file mode 100644
index 000000000..14a0e4978
--- /dev/null
+++ b/tableauserverclient/models/linked_tasks_item.py
@@ -0,0 +1,102 @@
+import datetime as dt
+from typing import Optional
+
+from defusedxml.ElementTree import fromstring
+
+from tableauserverclient.datetime_helpers import parse_datetime
+from tableauserverclient.models.schedule_item import ScheduleItem
+
+
+class LinkedTaskItem:
+ def __init__(self) -> None:
+ self.id: Optional[str] = None
+ self.num_steps: Optional[int] = None
+ self.schedule: Optional[ScheduleItem] = None
+
+ @classmethod
+ def from_response(cls, resp: bytes, namespace) -> list["LinkedTaskItem"]:
+ parsed_response = fromstring(resp)
+ return [
+ cls._parse_element(x, namespace)
+ for x in parsed_response.findall(".//t:linkedTasks[@id]", namespaces=namespace)
+ ]
+
+ @classmethod
+ def _parse_element(cls, xml, namespace) -> "LinkedTaskItem":
+ task = cls()
+ task.id = xml.get("id")
+ task.num_steps = int(xml.get("numSteps"))
+ task.schedule = ScheduleItem.from_element(xml, namespace)[0]
+ return task
+
+
+class LinkedTaskStepItem:
+ def __init__(self) -> None:
+ self.id: Optional[str] = None
+ self.step_number: Optional[int] = None
+ self.stop_downstream_on_failure: Optional[bool] = None
+ self.task_details: list[LinkedTaskFlowRunItem] = []
+
+ @classmethod
+ def from_task_xml(cls, xml, namespace) -> list["LinkedTaskStepItem"]:
+ return [cls._parse_element(x, namespace) for x in xml.findall(".//t:linkedTaskSteps[@id]", namespace)]
+
+ @classmethod
+ def _parse_element(cls, xml, namespace) -> "LinkedTaskStepItem":
+ step = cls()
+ step.id = xml.get("id")
+ step.step_number = int(xml.get("stepNumber"))
+ step.stop_downstream_on_failure = string_to_bool(xml.get("stopDownstreamTasksOnFailure"))
+ step.task_details = LinkedTaskFlowRunItem._parse_element(xml, namespace)
+ return step
+
+
+class LinkedTaskFlowRunItem:
+ def __init__(self) -> None:
+ self.flow_run_id: Optional[str] = None
+ self.flow_run_priority: Optional[int] = None
+ self.flow_run_consecutive_failed_count: Optional[int] = None
+ self.flow_run_task_type: Optional[str] = None
+ self.flow_id: Optional[str] = None
+ self.flow_name: Optional[str] = None
+
+ @classmethod
+ def _parse_element(cls, xml, namespace) -> list["LinkedTaskFlowRunItem"]:
+ all_tasks = []
+ for flow_run in xml.findall(".//t:flowRun[@id]", namespace):
+ task = cls()
+ task.flow_run_id = flow_run.get("id")
+ task.flow_run_priority = int(flow_run.get("priority"))
+ task.flow_run_consecutive_failed_count = int(flow_run.get("consecutiveFailedCount"))
+ task.flow_run_task_type = flow_run.get("type")
+ flow = flow_run.find(".//t:flow[@id]", namespace)
+ task.flow_id = flow.get("id")
+ task.flow_name = flow.get("name")
+ all_tasks.append(task)
+
+ return all_tasks
+
+
+class LinkedTaskJobItem:
+ def __init__(self) -> None:
+ self.id: Optional[str] = None
+ self.linked_task_id: Optional[str] = None
+ self.status: Optional[str] = None
+ self.created_at: Optional[dt.datetime] = None
+
+ @classmethod
+ def from_response(cls, resp: bytes, namespace) -> "LinkedTaskJobItem":
+ parsed_response = fromstring(resp)
+ job = cls()
+ job_xml = parsed_response.find(".//t:linkedTaskJob[@id]", namespaces=namespace)
+ if job_xml is None:
+ raise ValueError("No linked task job found in response")
+ job.id = job_xml.get("id")
+ job.linked_task_id = job_xml.get("linkedTaskId")
+ job.status = job_xml.get("status")
+ job.created_at = parse_datetime(job_xml.get("createdAt"))
+ return job
+
+
+def string_to_bool(s: str) -> bool:
+ return s.lower() == "true"
diff --git a/tableauserverclient/models/location_item.py b/tableauserverclient/models/location_item.py
new file mode 100644
index 000000000..fa7c2ff2c
--- /dev/null
+++ b/tableauserverclient/models/location_item.py
@@ -0,0 +1,53 @@
+from typing import Optional
+import xml.etree.ElementTree as ET
+
+
+class LocationItem:
+ """
+ Details of where an item is located, such as a personal space or project.
+
+ Attributes
+ ----------
+ id : str | None
+ The ID of the location.
+
+ type : str | None
+ The type of location, such as PersonalSpace or Project.
+
+ name : str | None
+ The name of the location.
+ """
+
+ class Type:
+ PersonalSpace = "PersonalSpace"
+ Project = "Project"
+
+ def __init__(self):
+ self._id: Optional[str] = None
+ self._type: Optional[str] = None
+ self._name: Optional[str] = None
+
+ def __repr__(self):
+ return f"{self.__class__.__name__}({self.__dict__!r})"
+
+ @property
+ def id(self) -> Optional[str]:
+ return self._id
+
+ @property
+ def type(self) -> Optional[str]:
+ return self._type
+
+ @property
+ def name(self) -> Optional[str]:
+ return self._name
+
+ @classmethod
+ def from_xml(cls, xml: ET.Element, ns: Optional[dict] = None) -> "LocationItem":
+ if ns is None:
+ ns = {}
+ location = cls()
+ location._id = xml.get("id", None)
+ location._type = xml.get("type", None)
+ location._name = xml.get("name", None)
+ return location
diff --git a/tableauserverclient/models/metric_item.py b/tableauserverclient/models/metric_item.py
new file mode 100644
index 000000000..432fd861a
--- /dev/null
+++ b/tableauserverclient/models/metric_item.py
@@ -0,0 +1,166 @@
+import xml.etree.ElementTree as ET
+from datetime import datetime
+from typing import Optional
+
+from tableauserverclient.datetime_helpers import parse_datetime
+from .property_decorators import property_is_boolean, property_is_datetime
+from .tag_item import TagItem
+from .permissions_item import Permission
+
+
+class MetricItem:
+ 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()
+ self._permissions: Optional[Permission] = None
+
+ @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 _set_permissions(self, permissions):
+ self._permissions = permissions
+
+ def __str__(self):
+ return "".format(**vars(self))
+
+ def __repr__(self):
+ return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}"
+
+ @classmethod
+ def from_response(
+ cls,
+ 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:
+ all_metric_items.append(cls.from_xml(metric_xml, ns))
+ return all_metric_items
+
+ @classmethod
+ def from_xml(cls, metric_xml, ns):
+ metric_item = cls()
+ metric_item._id = metric_xml.get("id", None)
+ metric_item._name = metric_xml.get("name", None)
+ metric_item._description = metric_xml.get("description", None)
+ metric_item._webpage_url = metric_xml.get("webpageUrl", None)
+ metric_item._created_at = parse_datetime(metric_xml.get("createdAt", None))
+ metric_item._updated_at = parse_datetime(metric_xml.get("updatedAt", None))
+ metric_item._suspended = string_to_bool(metric_xml.get("suspended", ""))
+ for owner in metric_xml.findall(".//t:owner", namespaces=ns):
+ metric_item._owner_id = owner.get("id", None)
+ for project in metric_xml.findall(".//t:project", namespaces=ns):
+ metric_item._project_id = project.get("id", None)
+ metric_item._project_name = project.get("name", None)
+ for view in metric_xml.findall(".//t:underlyingView", namespaces=ns):
+ metric_item._view_id = view.get("id", None)
+ tags = set()
+ tags_elem = metric_xml.find(".//t:tags", namespaces=ns)
+ if tags_elem is not None:
+ all_tags = TagItem.from_xml_element(tags_elem, ns)
+ tags = all_tags
+ metric_item.tags = tags
+ metric_item._initial_tags = tags
+ return metric_item
+
+
+# Used to convert string represented boolean to a boolean type
+def string_to_bool(s: str) -> bool:
+ return s.lower() == "true"
diff --git a/tableauserverclient/models/pagination_item.py b/tableauserverclient/models/pagination_item.py
index df9ca26e6..f30519be5 100644
--- a/tableauserverclient/models/pagination_item.py
+++ b/tableauserverclient/models/pagination_item.py
@@ -1,27 +1,30 @@
-import xml.etree.ElementTree as ET
+from defusedxml.ElementTree import fromstring
-class PaginationItem(object):
+class PaginationItem:
def __init__(self):
self._page_number = None
self._page_size = None
self._total_available = None
+ def __repr__(self):
+ return f""
+
@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 +34,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..bb3487279 100644
--- a/tableauserverclient/models/permissions_item.py
+++ b/tableauserverclient/models/permissions_item.py
@@ -1,11 +1,15 @@
import xml.etree.ElementTree as ET
-import logging
+from typing import Optional
-from .exceptions import UnknownGranteeTypeError
-from .user_item import UserItem
-from .group_item import GroupItem
+from defusedxml.ElementTree import fromstring
-logger = logging.getLogger("tableau.models.permissions_item")
+from tableauserverclient.models.exceptions import UnknownGranteeTypeError, UnpopulatedPropertyError
+from tableauserverclient.models.group_item import GroupItem
+from tableauserverclient.models.groupset_item import GroupSetItem
+from tableauserverclient.models.reference_item import ResourceReference
+from tableauserverclient.models.user_item import UserItem
+
+from tableauserverclient.helpers.logging import logger
class Permission:
@@ -13,6 +17,9 @@ class Mode:
Allow = "Allow"
Deny = "Deny"
+ def __repr__(self):
+ return ""
+
class Capability:
AddComment = "AddComment"
ChangeHierarchy = "ChangeHierarchy"
@@ -29,32 +36,80 @@ class Capability:
ShareView = "ShareView"
ViewComments = "ViewComments"
ViewUnderlyingData = "ViewUnderlyingData"
+ VizqlDataApiAccess = "VizqlDataApiAccess"
WebAuthoring = "WebAuthoring"
Write = "Write"
+ RunExplainData = "RunExplainData"
+ CreateRefreshMetrics = "CreateRefreshMetrics"
+ SaveAs = "SaveAs"
+ PulseMetricDefine = "PulseMetricDefine"
- class Resource:
- Workbook = "workbook"
- Datasource = "datasource"
- Flow = "flow"
- Table = "table"
- Database = "database"
- View = "view"
+ def __repr__(self):
+ return ""
-class PermissionsRule(object):
- def __init__(self, grantee, capabilities):
+class PermissionsRule:
+ def __init__(self, grantee: ResourceReference, capabilities: dict[str, str]) -> None:
self.grantee = grantee
self.capabilities = capabilities
+ def __repr__(self):
+ return f""
+
+ def __eq__(self, other: object) -> bool:
+ if not hasattr(other, "grantee") or not hasattr(other, "capabilities"):
+ return False
+ return self.grantee == other.grantee and self.capabilities == other.capabilities
+
+ def __and__(self, other: "PermissionsRule") -> "PermissionsRule":
+ if self.grantee != other.grantee:
+ raise ValueError("Cannot AND two permissions rules with different grantees")
+
+ if self.capabilities == other.capabilities:
+ return self
+
+ capabilities = {*self.capabilities.keys(), *other.capabilities.keys()}
+ new_capabilities = {}
+ for capability in capabilities:
+ if (self.capabilities.get(capability), other.capabilities.get(capability)) == (
+ Permission.Mode.Allow,
+ Permission.Mode.Allow,
+ ):
+ new_capabilities[capability] = Permission.Mode.Allow
+ elif Permission.Mode.Deny in (self.capabilities.get(capability), other.capabilities.get(capability)):
+ new_capabilities[capability] = Permission.Mode.Deny
+
+ return PermissionsRule(self.grantee, new_capabilities)
+
+ def __or__(self, other: "PermissionsRule") -> "PermissionsRule":
+ if self.grantee != other.grantee:
+ raise ValueError("Cannot OR two permissions rules with different grantees")
+
+ if self.capabilities == other.capabilities:
+ return self
+
+ capabilities = {*self.capabilities.keys(), *other.capabilities.keys()}
+ new_capabilities = {}
+ for capability in capabilities:
+ if Permission.Mode.Allow in (self.capabilities.get(capability), other.capabilities.get(capability)):
+ new_capabilities[capability] = Permission.Mode.Allow
+ elif (self.capabilities.get(capability), other.capabilities.get(capability)) == (
+ Permission.Mode.Deny,
+ Permission.Mode.Deny,
+ ):
+ new_capabilities[capability] = Permission.Mode.Deny
+
+ return PermissionsRule(self.grantee, new_capabilities)
+
@classmethod
- def from_response(cls, resp, ns=None):
- 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 +117,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(f"Capability was not valid: {capability_xml}")
+ raise UnpopulatedPropertyError()
+ else:
+ capability_dict[name] = mode
rule = PermissionsRule(grantee, capability_dict)
rules.append(rule)
@@ -70,7 +129,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
@@ -86,7 +145,9 @@ def _parse_grantee_element(grantee_capability_xml, ns):
grantee = UserItem.as_reference(grantee_id)
elif grantee_type == "group":
grantee = GroupItem.as_reference(grantee_id)
+ elif grantee_type == "groupSet":
+ grantee = GroupSetItem.as_reference(grantee_id)
else:
- raise UnknownGranteeTypeError("No support for grantee type of {}".format(grantee_type))
+ raise UnknownGranteeTypeError(f"No support for grantee type of {grantee_type}")
return grantee
diff --git a/tableauserverclient/models/personal_access_token_auth.py b/tableauserverclient/models/personal_access_token_auth.py
deleted file mode 100644
index a95972164..000000000
--- a/tableauserverclient/models/personal_access_token_auth.py
+++ /dev/null
@@ -1,17 +0,0 @@
-class PersonalAccessTokenAuth(object):
- def __init__(self, token_name, personal_access_token, site_id=""):
- self.token_name = token_name
- self.personal_access_token = personal_access_token
- self.site_id = site_id
- # Personal Access Tokens doesn't support impersonation.
- self.user_id_to_impersonate = None
-
- @property
- def credentials(self):
- return {
- "personalAccessTokenName": self.token_name,
- "personalAccessTokenSecret": self.personal_access_token,
- }
-
- def __repr__(self):
- return "".format(self.token_name, self.personal_access_token)
diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py
index 3a7d01143..1ab369ba7 100644
--- a/tableauserverclient/models/project_item.py
+++ b/tableauserverclient/models/project_item.py
@@ -1,107 +1,257 @@
import xml.etree.ElementTree as ET
+from typing import Optional, overload
-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 tableauserverclient.models.exceptions import UnpopulatedPropertyError
+from tableauserverclient.models.property_decorators import property_is_enum
+from tableauserverclient.models.user_item import UserItem
-class ProjectItem(object):
+class ProjectItem:
+ """
+ The project resources for Tableau are defined in the ProjectItem class. The
+ class corresponds to the project resources you can access using the Tableau
+ Server REST API.
+
+ Parameters
+ ----------
+ name : str
+ Name of the project.
+
+ description : str
+ Description of the project.
+
+ content_permissions : str
+ Sets or shows the permissions for the content in the project. The
+ options are either LockedToProject, ManagedByOwner or
+ LockedToProjectWithoutNested.
+
+ parent_id : str
+ The id of the parent project. Use this option to create project
+ hierarchies. For information about managing projects, project
+ hierarchies, and permissions, see
+ https://help.tableau.com/current/server/en-us/projects.htm
+
+ samples : bool
+ Set to True to include sample workbooks and data sources in the
+ project. The default is False.
+
+ Attributes
+ ----------
+ datasource_count : int
+ The number of data sources in the project.
+
+ id : str
+ The unique identifier for the project.
+
+ owner: Optional[UserItem]
+ The UserItem owner of the project.
+
+ owner_id : str
+ The unique identifier for the UserItem owner of the project.
+
+ project_count : int
+ The number of projects in the project.
+
+ top_level_project : bool
+ True if the project is a top-level project.
+
+ view_count : int
+ The number of views in the project.
+
+ workbook_count : int
+ The number of workbooks in the project.
+
+ writeable : bool
+ True if the project is writeable.
+ """
+
+ ERROR_MSG = "Project item must be populated with permissions first."
+
class ContentPermissions:
- LockedToProject = "LockedToProject"
- ManagedByOwner = "ManagedByOwner"
- LockedToProjectWithoutNested = "LockedToProjectWithoutNested"
+ LockedToProject: str = "LockedToProject"
+ ManagedByOwner: str = "ManagedByOwner"
+ LockedToProjectWithoutNested: str = "LockedToProjectWithoutNested"
- def __init__(self, name, description=None, content_permissions=None, parent_id=None):
+ def __repr__(self):
+ return "".format(
+ self._id, self.name, self.parent_id or "None (Top level)", self.content_permissions or "Not Set"
+ )
+
+ def __init__(
+ self,
+ name: Optional[str] = None,
+ description: Optional[str] = None,
+ content_permissions: Optional[str] = None,
+ parent_id: Optional[str] = None,
+ samples: Optional[bool] = None,
+ ) -> None:
self._content_permissions = None
- self._id = 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._samples: Optional[bool] = samples
+ self._owner_id: Optional[str] = None
+ self._top_level_project: Optional[bool] = None
+ self._writeable: Optional[bool] = None
self._permissions = None
self._default_workbook_permissions = None
self._default_datasource_permissions = None
self._default_flow_permissions = None
+ self._default_lens_permissions = None
+ self._default_datarole_permissions = None
+ self._default_metric_permissions = None
+ self._default_virtualconnection_permissions = None
+ self._default_database_permissions = None
+ self._default_table_permissions = None
+
+ self._project_count: Optional[int] = None
+ self._workbook_count: Optional[int] = None
+ self._view_count: Optional[int] = None
+ self._datasource_count: Optional[int] = None
+
+ self._owner: Optional[UserItem] = None
@property
def content_permissions(self):
return self._content_permissions
+ @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:
- error = "Project item must be populated with permissions first."
- raise UnpopulatedPropertyError(error)
+ raise UnpopulatedPropertyError(self.ERROR_MSG)
return self._permissions()
@property
def default_datasource_permissions(self):
if self._default_datasource_permissions is None:
- error = "Project item must be populated with permissions first."
- raise UnpopulatedPropertyError(error)
+ raise UnpopulatedPropertyError(self.ERROR_MSG)
return self._default_datasource_permissions()
@property
def default_workbook_permissions(self):
if self._default_workbook_permissions is None:
- error = "Project item must be populated with permissions first."
- raise UnpopulatedPropertyError(error)
+ raise UnpopulatedPropertyError(self.ERROR_MSG)
return self._default_workbook_permissions()
@property
def default_flow_permissions(self):
if self._default_flow_permissions is None:
- error = "Project item must be populated with permissions first."
- raise UnpopulatedPropertyError(error)
+ raise UnpopulatedPropertyError(self.ERROR_MSG)
return self._default_flow_permissions()
- @content_permissions.setter
- @property_is_enum(ContentPermissions)
- def content_permissions(self, value):
- self._content_permissions = value
+ @property
+ def default_lens_permissions(self):
+ if self._default_lens_permissions is None:
+ raise UnpopulatedPropertyError(self.ERROR_MSG)
+ return self._default_lens_permissions()
+
+ @property
+ def default_datarole_permissions(self):
+ if self._default_datarole_permissions is None:
+ raise UnpopulatedPropertyError(self.ERROR_MSG)
+ return self._default_datarole_permissions()
+
+ @property
+ def default_metric_permissions(self):
+ if self._default_metric_permissions is None:
+ raise UnpopulatedPropertyError(self.ERROR_MSG)
+ return self._default_metric_permissions()
+
+ @property
+ def default_virtualconnection_permissions(self):
+ if self._default_virtualconnection_permissions is None:
+ raise UnpopulatedPropertyError(self.ERROR_MSG)
+ return self._default_virtualconnection_permissions()
+
+ @property
+ def default_database_permissions(self):
+ if self._default_database_permissions is None:
+ raise UnpopulatedPropertyError(self.ERROR_MSG)
+ return self._default_database_permissions()
+
+ @property
+ def default_table_permissions(self):
+ if self._default_table_permissions is None:
+ raise UnpopulatedPropertyError(self.ERROR_MSG)
+ return self._default_table_permissions()
@property
- def id(self):
+ 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 owner_id(self):
+ def owner_id(self) -> Optional[str]:
return self._owner_id
@owner_id.setter
- def owner_id(self, value):
- raise NotImplementedError("REST API does not currently support updating project owner.")
+ def owner_id(self, value: str) -> None:
+ self._owner_id = value
+
+ @property
+ def top_level_project(self) -> Optional[bool]:
+ return self._top_level_project
+
+ @property
+ def writeable(self) -> Optional[bool]:
+ return self._writeable
+
+ @property
+ def project_count(self) -> Optional[int]:
+ return self._project_count
+
+ @property
+ def workbook_count(self) -> Optional[int]:
+ return self._workbook_count
+
+ @property
+ def view_count(self) -> Optional[int]:
+ return self._view_count
+
+ @property
+ def datasource_count(self) -> Optional[int]:
+ return self._datasource_count
+
+ @property
+ def owner(self) -> Optional[UserItem]:
+ return self._owner
def is_default(self):
return self.name.lower() == "default"
- def _parse_common_tags(self, project_xml, ns):
- if not isinstance(project_xml, ET.Element):
- project_xml = ET.fromstring(project_xml).find(".//t:project", namespaces=ns)
-
- if project_xml is not None:
- (
- _,
- name,
- description,
- content_permissions,
- parent_id,
- ) = self._parse_element(project_xml)
- self._set_values(None, name, description, content_permissions, parent_id)
- return self
-
- def _set_values(self, project_id, name, description, content_permissions, parent_id, owner_id):
+ def _set_values(
+ self,
+ project_id,
+ name,
+ description,
+ content_permissions,
+ parent_id,
+ owner_id,
+ top_level_project,
+ writeable,
+ project_count,
+ workbook_count,
+ view_count,
+ datasource_count,
+ owner,
+ ):
if project_id is not None:
self._id = project_id
if name:
@@ -114,46 +264,98 @@ def _set_values(self, project_id, name, description, content_permissions, parent
self.parent_id = parent_id
if owner_id:
self._owner_id = owner_id
+ if project_count is not None:
+ self._project_count = project_count
+ if workbook_count is not None:
+ self._workbook_count = workbook_count
+ if view_count is not None:
+ self._view_count = view_count
+ if datasource_count is not None:
+ self._datasource_count = datasource_count
+ if top_level_project is not None:
+ self._top_level_project = top_level_project
+ if writeable is not None:
+ self._writeable = writeable
+ if owner is not None:
+ self._owner = owner
def _set_permissions(self, permissions):
self._permissions = permissions
def _set_default_permissions(self, permissions, content_type):
+ attr = f"_default_{content_type}_permissions".lower()
setattr(
self,
- "_default_{content}_permissions".format(content=content_type),
+ attr,
permissions,
)
@classmethod
- def from_response(cls, resp, ns):
+ def from_response(cls, resp: bytes, ns: Optional[dict]) -> 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:
- (
- id,
- name,
- description,
- content_permissions,
- parent_id,
- owner_id,
- ) = cls._parse_element(project_xml)
- project_item = cls(name)
- project_item._set_values(id, name, description, content_permissions, parent_id, owner_id)
+ project_item = cls.from_xml(project_xml, namespace=ns)
all_project_items.append(project_item)
return all_project_items
+ @classmethod
+ def from_xml(cls, project_xml: ET.Element, namespace: Optional[dict] = None) -> "ProjectItem":
+ project_item = cls()
+ project_item._set_values(*cls._parse_element(project_xml, namespace))
+ return project_item
+
@staticmethod
- def _parse_element(project_xml):
+ def _parse_element(project_xml: ET.Element, namespace: Optional[dict]) -> tuple:
id = project_xml.get("id", None)
name = project_xml.get("name", None)
description = project_xml.get("description", None)
content_permissions = project_xml.get("contentPermissions", None)
parent_id = project_xml.get("parentProjectId", None)
+ top_level_project = str_to_bool(project_xml.get("topLevelProject", None))
+ writeable = str_to_bool(project_xml.get("writeable", None))
owner_id = None
- for owner in project_xml:
- owner_id = owner.get("id", None)
+ owner = None
+ if (owner_elem := project_xml.find(".//t:owner", namespaces=namespace)) is not None:
+ owner = UserItem.from_xml(owner_elem, namespace)
+ owner_id = owner_elem.get("id", None)
+
+ project_count = None
+ workbook_count = None
+ view_count = None
+ datasource_count = None
+ if (count_elem := project_xml.find(".//t:contentsCounts", namespaces=namespace)) is not None:
+ project_count = int(count_elem.get("projectCount", 0))
+ workbook_count = int(count_elem.get("workbookCount", 0))
+ view_count = int(count_elem.get("viewCount", 0))
+ datasource_count = int(count_elem.get("dataSourceCount", 0))
+
+ return (
+ id,
+ name,
+ description,
+ content_permissions,
+ parent_id,
+ owner_id,
+ top_level_project,
+ writeable,
+ project_count,
+ workbook_count,
+ view_count,
+ datasource_count,
+ owner,
+ )
+
+
+@overload
+def str_to_bool(value: str) -> bool: ...
+
+
+@overload
+def str_to_bool(value: None) -> None: ...
+
- return id, name, description, content_permissions, parent_id, owner_id
+def str_to_bool(value):
+ return value.lower() == "true" if value is not None else None
diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py
index ea2a62380..5048b3498 100644
--- a/tableauserverclient/models/property_decorators.py
+++ b/tableauserverclient/models/property_decorators.py
@@ -1,7 +1,10 @@
import datetime
import re
from functools import wraps
-from ..datetime_helpers import parse_datetime
+from typing import Any, Optional
+from collections.abc import Container
+
+from tableauserverclient.datetime_helpers import parse_datetime
def property_is_enum(enum_type):
@@ -9,7 +12,7 @@ def property_type_decorator(func):
@wraps(func)
def wrapper(self, value):
if value is not None and not hasattr(enum_type, value):
- error = "Invalid value: {0}. {1} must be of type {2}.".format(value, func.__name__, enum_type.__name__)
+ error = f"Invalid value: {value}. {func.__name__} must be of type {enum_type.__name__}."
raise ValueError(error)
return func(self, value)
@@ -22,7 +25,7 @@ def property_is_boolean(func):
@wraps(func)
def wrapper(self, value):
if not isinstance(value, bool):
- error = "Boolean expected for {0} flag.".format(func.__name__)
+ error = f"Boolean expected for {func.__name__} flag."
raise ValueError(error)
return func(self, value)
@@ -33,7 +36,7 @@ def property_not_nullable(func):
@wraps(func)
def wrapper(self, value):
if value is None:
- error = "{0} must be defined.".format(func.__name__)
+ error = f"{func.__name__} must be defined."
raise ValueError(error)
return func(self, value)
@@ -44,7 +47,7 @@ def property_not_empty(func):
@wraps(func)
def wrapper(self, value):
if not value:
- error = "{0} must not be empty.".format(func.__name__)
+ error = f"{func.__name__} must not be empty."
raise ValueError(error)
return func(self, value)
@@ -64,7 +67,7 @@ def wrapper(self, value):
return wrapper
-def property_is_int(range, allowed=None):
+def property_is_int(range: tuple[int, int], allowed: Optional[Container[Any]] = None):
"""Takes a range of ints and a list of exemptions to check against
when setting a property on a model. The range is a tuple of (min, max) and the
allowed list (empty by default) allows values outside that range.
@@ -79,7 +82,7 @@ def property_is_int(range, allowed=None):
def property_type_decorator(func):
@wraps(func)
def wrapper(self, value):
- error = "Invalid property defined: '{}'. Integer value expected.".format(value)
+ error = f"Invalid property defined: '{value}'. Integer value expected."
if range is None:
if isinstance(value, int):
@@ -88,8 +91,10 @@ def wrapper(self, value):
raise ValueError(error)
min, max = range
+ if value in allowed:
+ return func(self, value)
- if (value < min or value > max) and (value not in allowed):
+ if value < min or value > max:
raise ValueError(error)
return func(self, value)
@@ -100,7 +105,6 @@ def wrapper(self, value):
def property_matches(regex_to_match, error):
-
compiled_re = re.compile(regex_to_match)
def wrapper(func):
@@ -130,7 +134,7 @@ def wrapper(self, value):
return func(self, value)
if not isinstance(value, str):
raise ValueError(
- "Cannot convert {} into a datetime, cannot update {}".format(value.__class__.__name__, func.__name__)
+ f"Cannot convert {value.__class__.__name__} into a datetime, cannot update {func.__name__}"
)
dt = parse_datetime(value)
@@ -143,19 +147,11 @@ def property_is_data_acceleration_config(func):
@wraps(func)
def wrapper(self, value):
if not isinstance(value, dict):
- raise ValueError("{} is not type 'dict', cannot update {})".format(value.__class__.__name__, func.__name__))
- if len(value) != 4 or not all(
- attr in value.keys()
- for attr in (
- "acceleration_enabled",
- "accelerate_now",
- "last_updated_at",
- "acceleration_status",
- )
- ):
- error = "{} should have 2 keys ".format(func.__name__)
+ raise ValueError(f"{value.__class__.__name__} is not type 'dict', cannot update {func.__name__})")
+ if len(value) < 2 or not all(attr in value.keys() for attr in ("acceleration_enabled", "accelerate_now")):
+ error = f"{func.__name__} should have 2 keys "
error += "'acceleration_enabled' and 'accelerate_now'"
- error += "instead you have {}".format(value.keys())
+ error += f"instead you have {value.keys()}"
raise ValueError(error)
return func(self, value)
diff --git a/tableauserverclient/models/reference_item.py b/tableauserverclient/models/reference_item.py
index 48d2ab56a..4c1fff564 100644
--- a/tableauserverclient/models/reference_item.py
+++ b/tableauserverclient/models/reference_item.py
@@ -1,8 +1,18 @@
-class ResourceReference(object):
+class ResourceReference:
def __init__(self, id_, tag_name):
self.id = id_
self.tag_name = tag_name
+ def __str__(self):
+ return f""
+
+ __repr__ = __str__
+
+ def __eq__(self, other: object) -> bool:
+ if not hasattr(other, "id") or not hasattr(other, "tag_name"):
+ return False
+ return (self.id == other.id) and (self.tag_name == other.tag_name)
+
@property
def id(self):
return self._id
diff --git a/tableauserverclient/models/revision_item.py b/tableauserverclient/models/revision_item.py
new file mode 100644
index 000000000..1b4cc6249
--- /dev/null
+++ b/tableauserverclient/models/revision_item.py
@@ -0,0 +1,81 @@
+from datetime import datetime
+from typing import Optional
+
+from defusedxml.ElementTree import fromstring
+
+from tableauserverclient.datetime_helpers import parse_datetime
+
+
+class RevisionItem:
+ 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("current", ""))
+ revision_item._deleted = string_to_bool(revision_xml.get("deleted", ""))
+ revision_item._created_at = parse_datetime(revision_xml.get("publishedAt", None))
+ for user in revision_xml.findall(".//t:publisher", namespaces=ns):
+ revision_item._user_id = user.get("id", None)
+ revision_item._user_name = user.get("name", None)
+
+ 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..a2118e3d6 100644
--- a/tableauserverclient/models/schedule_item.py
+++ b/tableauserverclient/models/schedule_item.py
@@ -1,6 +1,10 @@
import xml.etree.ElementTree as ET
from datetime import datetime
+from typing import Optional, Union
+from defusedxml.ElementTree import fromstring
+
+from tableauserverclient.datetime_helpers import parse_datetime
from .interval_item import (
IntervalItem,
HourlyInterval,
@@ -10,18 +14,76 @@
)
from .property_decorators import (
property_is_enum,
- property_not_nullable,
- property_is_int,
)
-from ..datetime_helpers import parse_datetime
+Interval = Union[HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval]
+
+
+class ScheduleItem:
+ """
+ Using the TSC library, you can schedule extract refresh or subscription
+ tasks on Tableau Server. You can also get and update information about the
+ scheduled tasks, or delete scheduled tasks.
+
+ If you have the identifier of the job, you can use the TSC library to find
+ out the status of the asynchronous job.
+
+ The schedule properties are defined in the ScheduleItem class. The class
+ corresponds to the properties for schedules you can access in Tableau
+ Server or by using the Tableau Server REST API. The Schedule methods are
+ based upon the endpoints for jobs in the REST API and operate on the JobItem
+ class.
+
+ Parameters
+ ----------
+ name : str
+ The name of the schedule.
+
+ priority : int
+ The priority of the schedule. Lower values represent higher priority,
+ with 0 indicating the highest priority.
+
+ schedule_type : str
+ The type of task schedule. See ScheduleItem.Type for the possible values.
+
+ execution_order : str
+ Specifies how the scheduled tasks should run. The choices are Parallel
+ which uses all avaiable background processes for a scheduled task, or
+ Serial, which limits the schedule to one background process.
+
+ interval_item : Interval
+ Specifies the frequency that the scheduled task should run. The
+ interval_item is an instance of the IntervalItem class. The
+ interval_item has properties for frequency (hourly, daily, weekly,
+ monthly), and what time and date the scheduled item runs. You set this
+ value by declaring an IntervalItem object that is one of the following:
+ HourlyInterval, DailyInterval, WeeklyInterval, or MonthlyInterval.
+
+ Attributes
+ ----------
+ created_at : datetime
+ The date and time the schedule was created.
+
+ end_schedule_at : datetime
+ The date and time the schedule ends.
+
+ id : str
+ The unique identifier for the schedule.
+
+ next_run_at : datetime
+ The date and time the schedule is next run.
+
+ state : str
+ The state of the schedule. See ScheduleItem.State for the possible values.
+ """
-class ScheduleItem(object):
class Type:
Extract = "Extract"
Flow = "Flow"
Subscription = "Subscription"
DataAcceleration = "DataAcceleration"
+ ActiveDirectorySync = "ActiveDirectorySync"
+ System = "System"
class ExecutionOrder:
Parallel = "Parallel"
@@ -31,86 +93,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 __str__(self):
+ return ''.format(**vars(self))
def __repr__(self):
- return ''.format(**self.__dict__)
+ return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}"
@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) -> Optional[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 +181,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 +255,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
@@ -250,25 +312,43 @@ def _parse_interval_item(parsed_response, frequency, ns):
interval.extend(interval_elem.attrib.items())
if frequency == IntervalItem.Frequency.Daily:
- return DailyInterval(start_time)
+ converted_intervals = []
+
+ for i in interval:
+ # We use fractional hours for the two minute-based intervals.
+ # Need to convert to hours from minutes here
+ if i[0] == IntervalItem.Occurrence.Minutes:
+ converted_intervals.append(float(i[1]) / 60)
+ elif i[0] == IntervalItem.Occurrence.Hours:
+ converted_intervals.append(float(i[1]))
+ else:
+ converted_intervals.append(i[1])
+
+ return DailyInterval(start_time, *converted_intervals)
if frequency == IntervalItem.Frequency.Hourly:
- interval_occurrence, interval_value = interval.pop()
+ converted_intervals = []
- # We use fractional hours for the two minute-based intervals.
- # Need to convert to hours from minutes here
- if interval_occurrence == IntervalItem.Occurrence.Minutes:
- interval_value = float(interval_value) / 60
+ for i in interval:
+ # We use fractional hours for the two minute-based intervals.
+ # Need to convert to hours from minutes here
+ if i[0] == IntervalItem.Occurrence.Minutes:
+ converted_intervals.append(float(i[1]) / 60)
+ elif i[0] == IntervalItem.Occurrence.Hours:
+ converted_intervals.append(i[1])
+ else:
+ converted_intervals.append(i[1])
- return HourlyInterval(start_time, end_time, interval_value)
+ return HourlyInterval(start_time, end_time, tuple(converted_intervals))
if frequency == IntervalItem.Frequency.Weekly:
interval_values = [i[1] for i in interval]
return WeeklyInterval(start_time, *interval_values)
if frequency == IntervalItem.Frequency.Monthly:
- interval_occurrence, interval_value = interval.pop()
- return MonthlyInterval(start_time, interval_value)
+ interval_values = [i[1] for i in interval]
+
+ return MonthlyInterval(start_time, tuple(interval_values))
@staticmethod
def _parse_element(schedule_xml, ns):
@@ -308,12 +388,12 @@ 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)
error = (
- "Status {}: {}".format(response.status_code, response.reason)
+ f"Status {response.status_code}: {response.reason}"
if response.status_code < 200 or response.status_code >= 300
else None
)
diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py
index 1f6604662..b13f26740 100644
--- a/tableauserverclient/models/server_info_item.py
+++ b/tableauserverclient/models/server_info_item.py
@@ -1,12 +1,50 @@
-import xml.etree.ElementTree as ET
+import logging
+import warnings
+import xml
+from defusedxml.ElementTree import fromstring
+from tableauserverclient.helpers.logging import logger
+
+
+class ServerInfoItem:
+ """
+ The ServerInfoItem class contains the build and version information for
+ Tableau Server. The server information is accessed with the
+ server_info.get() method, which returns an instance of the ServerInfo class.
+
+ Attributes
+ ----------
+ product_version : str
+ Shows the version of the Tableau Server or Tableau Cloud
+ (for example, 10.2.0).
+
+ build_number : str
+ Shows the specific build number (for example, 10200.17.0329.1446).
+
+ rest_api_version : str
+ Shows the supported REST API version number. Note that this might be
+ different from the default value specified for the server, with the
+ Server.version attribute. To take advantage of new features, you should
+ query the server and set the Server.version to match the supported REST
+ API version number.
+ """
-class ServerInfoItem(object):
def __init__(self, product_version, build_number, rest_api_version):
self._product_version = product_version
self._build_number = build_number
self._rest_api_version = rest_api_version
+ def __repr__(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 +59,15 @@ def rest_api_version(self):
@classmethod
def from_response(cls, resp, ns):
- parsed_response = ET.fromstring(resp)
+ try:
+ parsed_response = fromstring(resp)
+ except xml.etree.ElementTree.ParseError as error:
+ logger.exception(f"Unexpected response for ServerInfo: {resp}")
+ return cls("Unknown", "Unknown", "Unknown")
+ except Exception as error:
+ logger.exception(f"Unexpected response for ServerInfo: {resp}")
+ raise error
+
product_version_tag = parsed_response.find(".//t:productVersion", namespaces=ns)
rest_api_version_tag = parsed_response.find(".//t:restApiVersion", namespaces=ns)
diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py
index ab0211414..ab65b97b5 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,165 @@
property_is_int,
)
-
VALID_CONTENT_URL_RE = r"^[a-zA-Z0-9_\-]*$"
+from typing import Optional, Union, TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from tableauserverclient.server import Server
+
+
+class SiteItem:
+ """
+ The SiteItem class contains the members or attributes for the site resources
+ on Tableau Server or Tableau Cloud. The SiteItem class defines the
+ information you can request or query from Tableau Server or Tableau Cloud.
+ The class members correspond to the attributes of a server request or
+ response payload.
+
+ Attributes
+ ----------
+ name: str
+ The name of the site. The name of the default site is "".
+
+ content_url: str
+ The path to the site.
+
+ admin_mode: str
+ (Optional) For Tableau Server only. Specify ContentAndUsers to allow
+ site administrators to use the server interface and tabcmd commands to
+ add and remove users. (Specifying this option does not give site
+ administrators permissions to manage users using the REST API.) Specify
+ ContentOnly to prevent site administrators from adding or removing
+ users. (Server administrators can always add or remove users.)
+
+ user_quota: int
+ (Optional) Specifies the total number of users for the site. The number
+ can't exceed the number of licenses activated for the site; and if
+ tiered capacity attributes are set, then user_quota will equal the sum
+ of the tiered capacity values, and attempting to set user_quota will
+ cause an error.
+
+ tier_explorer_capacity: int
+ tier_creator_capacity: int
+ tier_viewer_capacity: int
+ (Optional) The maximum number of licenses for users with the Creator,
+ Explorer, or Viewer role, respectively, allowed on a site.
+
+ storage_quota: int
+ (Optional) Specifies the maximum amount of space for the new site, in
+ megabytes. If you set a quota and the site exceeds it, publishers will
+ be prevented from uploading new content until the site is under the
+ limit again.
+
+ disable_subscriptions: bool
+ (Optional) Specify true to prevent users from being able to subscribe
+ to workbooks on the specified site. The default is False.
+
+ subscribe_others_enabled: bool
+ (Optional) Specify false to prevent server administrators, site
+ administrators, and project or content owners from being able to
+ subscribe other users to workbooks on the specified site. The default
+ is True.
+
+ revision_history_enabled: bool
+ (Optional) Specify true to enable revision history for content resources
+ (workbooks and datasources). The default is False.
+
+ revision_limit: int
+ (Optional) Specifies the number of revisions of a content source
+ (workbook or data source) to allow. On Tableau Server, the default is
+ 25.
+
+ state: str
+ Shows the current state of the site (Active or Suspended).
+
+ """
+
+ _user_quota: Optional[int] = None
+ _tier_creator_capacity: Optional[int] = None
+ _tier_explorer_capacity: Optional[int] = None
+ _tier_viewer_capacity: Optional[int] = None
+
+ def __str__(self):
+ return (
+ "<"
+ + __name__
+ + ": "
+ + (self.name or "unnamed")
+ + ", "
+ + (self.id or "unknown-id")
+ + ", "
+ + (self.state or "unknown-state")
+ + ">"
+ )
+
+ def __repr__(self):
+ return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}"
-class SiteItem(object):
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: Optional[str] = None,
+ user_quota: Optional[int] = None,
+ storage_quota: Optional[int] = None,
+ disable_subscriptions: bool = False,
+ subscribe_others_enabled: bool = True,
+ revision_history_enabled: bool = False,
+ 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 +219,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 +237,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 +267,363 @@ 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
+ def is_default(self) -> bool:
+ return self.name.lower() == "default"
+
+ @staticmethod
+ def use_new_flow_settings(parent_srv: "Server") -> bool:
+ return parent_srv is not None and parent_srv.check_at_least_version("3.10")
+
@property
- def flows_enabled(self):
+ 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:
+ # Flows Enabled' is not a supported site setting in API Version [3.17].
+ # In Version 3.10+ use the more granular settings 'Editing Flows Enabled' and/or 'Scheduling Flows Enabled'
self._flows_enabled = value
- def is_default(self):
- 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 +651,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 +850,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 +939,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:
(
@@ -1057,6 +1188,34 @@ def _parse_element(site_xml, ns):
)
+class SiteAuthConfiguration:
+ """
+ Authentication configuration for a site.
+ """
+
+ def __init__(self):
+ self.auth_setting: Optional[str] = None
+ self.enabled: Optional[bool] = None
+ self.idp_configuration_id: Optional[str] = None
+ self.idp_configuration_name: Optional[str] = None
+ self.known_provider_alias: Optional[str] = None
+
+ @classmethod
+ def from_response(cls, resp: bytes, ns: dict) -> list["SiteAuthConfiguration"]:
+ all_auth_configs = list()
+ parsed_response = fromstring(resp)
+ all_auth_xml = parsed_response.findall(".//t:siteAuthConfiguration", namespaces=ns)
+ for auth_xml in all_auth_xml:
+ auth_config = cls()
+ auth_config.auth_setting = auth_xml.get("authSetting", None)
+ auth_config.enabled = string_to_bool(auth_xml.get("enabled", ""))
+ auth_config.idp_configuration_id = auth_xml.get("idpConfigurationId", None)
+ auth_config.idp_configuration_name = auth_xml.get("idpConfigurationName", None)
+ auth_config.known_provider_alias = auth_xml.get("knownProviderAlias", None)
+ all_auth_configs.append(auth_config)
+ return all_auth_configs
+
+
# Used to convert string represented boolean to a boolean type
-def string_to_bool(s):
+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..61c75e2d6 100644
--- a/tableauserverclient/models/subscription_item.py
+++ b/tableauserverclient/models/subscription_item.py
@@ -1,10 +1,17 @@
-import xml.etree.ElementTree as ET
-from .target import Target
+from typing import TYPE_CHECKING
+
+from defusedxml.ElementTree import fromstring
+
from .property_decorators import property_is_boolean
+from .target import Target
+from tableauserverclient.models import ScheduleItem
+
+if TYPE_CHECKING:
+ from .target import Target
-class SubscriptionItem(object):
- def __init__(self, subject, schedule_id, user_id, target):
+class SubscriptionItem:
+ 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
@@ -17,8 +24,9 @@ def __init__(self, subject, schedule_id, user_id, target):
self.suspended = False
self.target = target
self.user_id = user_id
+ self.schedule = None
- 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]
@@ -86,9 +94,14 @@ def _parse_element(cls, element, ns):
# Schedule element
schedule_id = None
+ schedule = None
if schedule_element is not None:
schedule_id = schedule_element.get("id", None)
+ # If schedule id is not provided, then TOL with full schedule provided
+ if schedule_id is None:
+ schedule = ScheduleItem.from_element(element, ns)
+
# Content element
target = None
send_if_view_empty = None
@@ -121,10 +134,11 @@ def _parse_element(cls, element, ns):
sub.page_size_option = page_size_option
sub.send_if_view_empty = send_if_view_empty
sub.suspended = suspended
+ sub.schedule = schedule
return sub
# 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..541f84360 100644
--- a/tableauserverclient/models/table_item.py
+++ b/tableauserverclient/models/table_item.py
@@ -1,10 +1,14 @@
-import xml.etree.ElementTree as ET
+from typing import Callable, Optional, TYPE_CHECKING
+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
+
+if TYPE_CHECKING:
+ from tableauserverclient.models import DQWItem
-class TableItem(object):
+class TableItem:
def __init__(self, name, description=None):
self._id = None
self.description = description
@@ -19,6 +23,12 @@ def __init__(self, name, description=None):
self._columns = None
self._data_quality_warnings = None
+ def __str__(self):
+ return f"<{self.__class__.__name__} {self._id} {self._name} >"
+
+ def __repr__(self):
+ return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}"
+
@property
def permissions(self):
if self._permissions is None:
@@ -34,7 +44,7 @@ def dqws(self):
return self._data_quality_warnings()
@property
- def id(self):
+ def id(self) -> Optional[str]:
return self._id
@property
@@ -94,8 +104,8 @@ def columns(self):
def _set_columns(self, columns):
self._columns = columns
- def _set_data_quality_warnings(self, dqws):
- self._data_quality_warnings = dqws
+ def _set_data_quality_warnings(self, dqw: Callable[[], list["DQWItem"]]) -> None:
+ self._data_quality_warnings = dqw
def _set_values(self, table_values):
if "id" in table_values:
@@ -128,7 +138,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:
@@ -140,7 +150,6 @@ def from_response(cls, resp, ns):
@staticmethod
def _parse_element(table_xml, ns):
-
table_values = table_xml.attrib.copy()
contact = table_xml.find(".//t:contact", namespaces=ns)
diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py
index 01787de4e..7d7981433 100644
--- a/tableauserverclient/models/tableau_auth.py
+++ b/tableauserverclient/models/tableau_auth.py
@@ -1,39 +1,216 @@
-class TableauAuth(object):
- def __init__(self, username, password, site=None, site_id="", user_id_to_impersonate=None):
- if site is not None:
- import warnings
-
- warnings.warn(
- 'TableauAuth(...site=""...) is deprecated, ' 'please use TableauAuth(...site_id=""...) instead.',
- DeprecationWarning,
- )
- site_id = site
-
- self.user_id_to_impersonate = user_id_to_impersonate
+import abc
+from typing import Optional
+
+
+class Credentials(abc.ABC):
+ def __init__(self, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None) -> None:
+ self.site_id = site_id or ""
+ self.user_id_to_impersonate = user_id_to_impersonate or None
+
+ @property
+ @abc.abstractmethod
+ def credentials(self) -> dict[str, str]:
+ credentials = (
+ "Credentials can be username/password, Personal Access Token, or JWT"
+ "This method returns values to set as an attribute on the credentials element of the request"
+ )
+ return {"key": "value"}
+
+ @abc.abstractmethod
+ def __repr__(self):
+ return "All Credentials types must have a debug display that does not print secrets"
+
+
+def deprecate_site_attribute():
+ import warnings
+
+ warnings.warn(
+ "TableauAuth(..., site=...) is deprecated, " "please use TableauAuth(..., site_id=...) instead.",
+ DeprecationWarning,
+ )
+
+
+# The traditional auth type: username/password
+class TableauAuth(Credentials):
+ """
+ The TableauAuth class defines the information you can set in a sign-in
+ request. The class members correspond to the attributes of a server request
+ or response payload. To use this class, create a new instance, supplying
+ user name, password, and site information if necessary, and pass the
+ request object to the Auth.sign_in method.
+
+ Parameters
+ ----------
+ username : str
+ The user name for the sign-in request.
+
+ password : str
+ The password for the sign-in request.
+
+ site_id : str, optional
+ This corresponds to the contentUrl attribute in the Tableau REST API.
+ The site_id is the portion of the URL that follows the /site/ in the
+ URL. For example, "MarketingTeam" is the site_id in the following URL
+ MyServer/#/site/MarketingTeam/projects. To specify the default site on
+ Tableau Server, you can use an empty string '' (single quotes, no
+ space). For Tableau Cloud, you must provide a value for the site_id.
+
+ user_id_to_impersonate : str, optional
+ Specifies the id (not the name) of the user to sign in as. This is not
+ available for Tableau Online.
+
+ Examples
+ --------
+ >>> import tableauserverclient as TSC
+
+ >>> tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD', site_id='CONTENTURL')
+ >>> server = TSC.Server('https://SERVER_URL', use_server_version=True)
+ >>> server.auth.sign_in(tableau_auth)
+
+ """
+
+ def __init__(
+ self, username: str, password: str, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None
+ ) -> None:
+ super().__init__(site_id, user_id_to_impersonate)
+ if password is None:
+ raise TabError("Must provide a password when using traditional authentication")
self.password = password
- self.site_id = site_id
self.username = username
@property
- def site(self):
- import warnings
+ def credentials(self) -> dict[str, str]:
+ return {"name": self.username, "password": self.password}
+
+ def __repr__(self):
+ if self.user_id_to_impersonate:
+ uid = f", user_id_to_impersonate=f{self.user_id_to_impersonate}"
+ else:
+ uid = ""
+ return f""
- warnings.warn(
- "TableauAuth.site is deprecated, use TableauAuth.site_id instead.",
- DeprecationWarning,
- )
- return self.site_id
- @site.setter
- def site(self, value):
- import warnings
+# A Tableau-generated Personal Access Token
+class PersonalAccessTokenAuth(Credentials):
+ """
+ The PersonalAccessTokenAuth class defines the information you can set in a sign-in
+ request. The class members correspond to the attributes of a server request
+ or response payload. To use this class, create a new instance, supplying
+ token name, token secret, and site information if necessary, and pass the
+ request object to the Auth.sign_in method.
- warnings.warn(
- "TableauAuth.site is deprecated, use TableauAuth.site_id instead.",
- DeprecationWarning,
+ Parameters
+ ----------
+ token_name : str
+ The name of the personal access token.
+
+ personal_access_token : str
+ The personal access token secret for the sign in request.
+
+ site_id : str, optional
+ This corresponds to the contentUrl attribute in the Tableau REST API.
+ The site_id is the portion of the URL that follows the /site/ in the
+ URL. For example, "MarketingTeam" is the site_id in the following URL
+ MyServer/#/site/MarketingTeam/projects. To specify the default site on
+ Tableau Server, you can use an empty string '' (single quotes, no
+ space). For Tableau Cloud, you must provide a value for the site_id.
+
+ user_id_to_impersonate : str, optional
+ Specifies the id (not the name) of the user to sign in as. This is not
+ available for Tableau Online.
+
+ Examples
+ --------
+ >>> import tableauserverclient as TSC
+
+ >>> tableau_auth = TSC.PersonalAccessTokenAuth("token_name", "token_secret", site_id='CONTENTURL')
+ >>> server = TSC.Server('https://SERVER_URL', use_server_version=True)
+ >>> server.auth.sign_in(tableau_auth)
+
+ """
+
+ def __init__(
+ self,
+ token_name: str,
+ personal_access_token: str,
+ site_id: Optional[str] = None,
+ user_id_to_impersonate: Optional[str] = None,
+ ) -> None:
+ if personal_access_token is None or token_name is None:
+ raise TabError("Must provide a token and token name when using PAT authentication")
+ super().__init__(site_id=site_id, user_id_to_impersonate=user_id_to_impersonate)
+ self.token_name = token_name
+ self.personal_access_token = personal_access_token
+
+ @property
+ def credentials(self) -> dict[str, str]:
+ return {
+ "personalAccessTokenName": self.token_name,
+ "personalAccessTokenSecret": self.personal_access_token,
+ }
+
+ def __repr__(self):
+ if self.user_id_to_impersonate:
+ uid = f", user_id_to_impersonate=f{self.user_id_to_impersonate}"
+ else:
+ uid = ""
+ return (
+ f""
)
- self.site_id = value
+
+
+# A standard JWT generated specifically for Tableau
+class JWTAuth(Credentials):
+ """
+ The JWTAuth class defines the information you can set in a sign-in
+ request. The class members correspond to the attributes of a server request
+ or response payload. To use this class, create a new instance, supplying
+ an encoded JSON Web Token, and site information if necessary, and pass the
+ request object to the Auth.sign_in method.
+
+ Parameters
+ ----------
+ token : str
+ The encoded JSON Web Token.
+
+ site_id : str, optional
+ This corresponds to the contentUrl attribute in the Tableau REST API.
+ The site_id is the portion of the URL that follows the /site/ in the
+ URL. For example, "MarketingTeam" is the site_id in the following URL
+ MyServer/#/site/MarketingTeam/projects. To specify the default site on
+ Tableau Server, you can use an empty string '' (single quotes, no
+ space). For Tableau Cloud, you must provide a value for the site_id.
+
+ user_id_to_impersonate : str, optional
+ Specifies the id (not the name) of the user to sign in as. This is not
+ available for Tableau Online.
+
+ Examples
+ --------
+ >>> import jwt
+ >>> import tableauserverclient as TSC
+
+ >>> jwt_token = jwt.encode(...)
+ >>> tableau_auth = TSC.JWTAuth(token, site_id='CONTENTURL')
+ >>> server = TSC.Server('https://SERVER_URL', use_server_version=True)
+ >>> server.auth.sign_in(tableau_auth)
+
+ """
+
+ def __init__(self, jwt: str, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None) -> None:
+ if jwt is None:
+ raise TabError("Must provide a JWT token when using JWT authentication")
+ super().__init__(site_id, user_id_to_impersonate)
+ self.jwt = jwt
@property
- def credentials(self):
- return {"name": self.username, "password": self.password}
+ def credentials(self) -> dict[str, str]:
+ return {"jwt": self.jwt}
+
+ def __repr__(self):
+ if self.user_id_to_impersonate:
+ uid = f", user_id_to_impersonate=f{self.user_id_to_impersonate}"
+ else:
+ uid = ""
+ return f"<{self.__class__.__qualname__} jwt={self.jwt[:5]}... (site={self.site_id}{uid})>"
diff --git a/tableauserverclient/models/tableau_types.py b/tableauserverclient/models/tableau_types.py
new file mode 100644
index 000000000..e69d02a06
--- /dev/null
+++ b/tableauserverclient/models/tableau_types.py
@@ -0,0 +1,47 @@
+from typing import Union
+
+from tableauserverclient.models.database_item import DatabaseItem
+from tableauserverclient.models.datasource_item import DatasourceItem
+from tableauserverclient.models.flow_item import FlowItem
+from tableauserverclient.models.project_item import ProjectItem
+from tableauserverclient.models.table_item import TableItem
+from tableauserverclient.models.view_item import ViewItem
+from tableauserverclient.models.workbook_item import WorkbookItem
+from tableauserverclient.models.metric_item import MetricItem
+from tableauserverclient.models.virtual_connection_item import VirtualConnectionItem
+
+
+class Resource:
+ Database = "database"
+ Datarole = "datarole"
+ Table = "table"
+ Datasource = "datasource"
+ Flow = "flow"
+ Lens = "lens"
+ Metric = "metric"
+ Project = "project"
+ View = "view"
+ VirtualConnection = "virtualConnection"
+ Workbook = "workbook"
+
+
+# resource types that have permissions, can be renamed, etc
+# todo: refactoring: should actually define TableauItem as an interface and let all these implement it
+TableauItem = Union[
+ DatasourceItem,
+ FlowItem,
+ MetricItem,
+ ProjectItem,
+ ViewItem,
+ WorkbookItem,
+ VirtualConnectionItem,
+ DatabaseItem,
+ TableItem,
+]
+
+
+def plural_type(content_type: Union[Resource, str]) -> str:
+ if content_type == Resource.Lens:
+ return "lenses"
+ else:
+ return f"{content_type}s"
diff --git a/tableauserverclient/models/tag_item.py b/tableauserverclient/models/tag_item.py
index 055b04634..cde755f05 100644
--- a/tableauserverclient/models/tag_item.py
+++ b/tableauserverclient/models/tag_item.py
@@ -1,13 +1,15 @@
import xml.etree.ElementTree as ET
+from defusedxml.ElementTree import fromstring
-class TagItem(object):
+
+class TagItem:
@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..8d2492aed 100644
--- a/tableauserverclient/models/task_item.py
+++ b/tableauserverclient/models/task_item.py
@@ -1,30 +1,66 @@
-import xml.etree.ElementTree as ET
-from .target import Target
-from .schedule_item import ScheduleItem
-from ..datetime_helpers import parse_datetime
+from datetime import datetime
+from typing import Optional
+from defusedxml.ElementTree import fromstring
+
+from tableauserverclient.datetime_helpers import parse_datetime
+from tableauserverclient.models.schedule_item import ScheduleItem
+from tableauserverclient.models.target import Target
+
+
+class TaskItem:
+ """
+ Represents a task item in Tableau Server. To create new tasks, see Schedules.
+
+ Parameters
+ ----------
+ id_ : str
+ The ID of the task.
+
+ task_type : str
+ Type of task. See TaskItem.Type for possible values.
+
+ priority : int
+ The priority of the task on the server.
+
+ consecutive_failed_count : int
+ The number of consecutive times the task has failed.
+
+ schedule_id : str, optional
+ The ID of the schedule that the task is associated with.
+
+ schedule_item : ScheduleItem, optional
+ The schedule item that the task is associated with.
+
+ last_run_at : datetime, optional
+ The last time the task was run.
+
+ target : Target, optional
+ The target of the task. This can be a workbook or a datasource.
+ """
-class TaskItem(object):
class Type:
ExtractRefresh = "extractRefresh"
DataAcceleration = "dataAcceleration"
+ RunFlow = "runFlow"
# This mapping is used to convert task type returned from server
_TASK_TYPE_MAPPING = {
"RefreshExtractTask": Type.ExtractRefresh,
"MaterializeViewsTask": Type.DataAcceleration,
+ "RunFlowTask": Type.RunFlow,
}
def __init__(
self,
- id_,
- task_type,
- priority,
- consecutive_failed_count=0,
- schedule_id=None,
- schedule_item=None,
- last_run_at=None,
- target=None,
+ id_: str,
+ task_type: str,
+ priority: int,
+ consecutive_failed_count: int = 0,
+ schedule_id: Optional[str] = None,
+ schedule_item: Optional[ScheduleItem] = None,
+ last_run_at: Optional[datetime] = None,
+ target: Optional[Target] = None,
):
self.id = id_
self.task_type = task_type
@@ -35,16 +71,16 @@ def __init__(
self.last_run_at = last_run_at
self.target = target
- def __repr__(self):
+ def __repr__(self) -> str:
return (
"".format(**self.__dict__)
)
@classmethod
- def from_response(cls, xml, ns, task_type=Type.ExtractRefresh):
- parsed_response = ET.fromstring(xml)
- all_tasks_xml = parsed_response.findall(".//t:task/t:{}".format(task_type), namespaces=ns)
+ def from_response(cls, xml, ns, task_type=Type.ExtractRefresh) -> list["TaskItem"]:
+ parsed_response = fromstring(xml)
+ all_tasks_xml = parsed_response.findall(f".//t:task/t:{task_type}", namespaces=ns)
all_tasks = (TaskItem._parse_element(x, ns) for x in all_tasks_xml)
@@ -60,8 +96,7 @@ def _parse_element(cls, element, ns):
last_run_at_element = element.find(".//t:lastRunAt", namespaces=ns)
schedule_item_list = ScheduleItem.from_element(element, ns)
- if len(schedule_item_list) >= 1:
- schedule_item = schedule_item_list[0]
+ schedule_item = next(iter(schedule_item_list), None)
# according to the Tableau Server REST API documentation,
# there should be only one of workbook or datasource
@@ -85,14 +120,14 @@ def _parse_element(cls, element, ns):
task_type,
priority,
consecutive_failed_count,
- schedule_item.id,
+ schedule_item.id if schedule_item is not None else None,
schedule_item,
last_run_at,
target,
)
@staticmethod
- def _translate_task_type(task_type):
+ def _translate_task_type(task_type: str) -> str:
if task_type in TaskItem._TASK_TYPE_MAPPING:
return TaskItem._TASK_TYPE_MAPPING[task_type]
else:
diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py
index 65abf4cb6..c995b4e07 100644
--- a/tableauserverclient/models/user_item.py
+++ b/tableauserverclient/models/user_item.py
@@ -1,19 +1,96 @@
+import io
import xml.etree.ElementTree as ET
+from datetime import datetime
+from enum import IntEnum
+from typing import Optional, TYPE_CHECKING
+
+from defusedxml.ElementTree import fromstring
+
+from tableauserverclient.datetime_helpers import parse_datetime
+from tableauserverclient.models.site_item import SiteAuthConfiguration
from .exceptions import UnpopulatedPropertyError
from .property_decorators import (
property_is_enum,
property_not_empty,
- property_not_nullable,
)
-from ..datetime_helpers import parse_datetime
from .reference_item import ResourceReference
+if TYPE_CHECKING:
+ from tableauserverclient.server import Pager
+
+
+class UserItem:
+ """
+ The UserItem class contains the members or attributes for the view
+ resources on Tableau Server. The UserItem class defines the information you
+ can request or query from Tableau Server. The class attributes correspond
+ to the attributes of a server request or response payload.
+
+
+ Parameters
+ ----------
+ name: str
+ The name of the user.
+
+ site_role: str
+ The role of the user on the site.
+
+ auth_setting: str
+ Required attribute for Tableau Cloud. How the user autenticates to the
+ server.
+
+ Attributes
+ ----------
+ domain_name: Optional[str]
+ The name of the Active Directory domain ("local" if local authentication
+ is used).
+
+ email: Optional[str]
+ The email address of the user.
+
+ external_auth_user_id: Optional[str]
+ The unique identifier for the user in the external authentication system.
-class UserItem(object):
+ id: Optional[str]
+ The unique identifier for the user.
- tag_name = "user"
+ favorites: dict[str, list]
+ The favorites of the user. Must be populated with a call to
+ `populate_favorites()`.
+
+ fullname: Optional[str]
+ The full name of the user.
+
+ groups: Pager
+ The groups the user belongs to. Must be populated with a call to
+ `populate_groups()`.
+
+ last_login: Optional[datetime]
+ The last time the user logged in.
+
+ locale: Optional[str]
+ The locale of the user.
+
+ language: Optional[str]
+ Language setting for the user.
+
+ idp_configuration_id: Optional[str]
+ The ID of the identity provider configuration.
+
+ workbooks: Pager
+ The workbooks owned by the user. Must be populated with a call to
+ `populate_workbooks()`.
+
+ """
+
+ tag_name: str = "user"
class Roles:
+ """
+ The Roles class contains the possible roles for a user on Tableau
+ Server.
+ """
+
Interactor = "Interactor"
Publisher = "Publisher"
ServerAdministrator = "ServerAdministrator"
@@ -35,27 +112,47 @@ class Roles:
SupportUser = "SupportUser"
class Auth:
+ """
+ The Auth class contains the possible authentication settings for a user
+ on Tableau Cloud.
+ """
+
OpenID = "OpenID"
SAML = "SAML"
+ TableauIDWithMFA = "TableauIDWithMFA"
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
+ self._locale: Optional[str] = None
+ self._language: Optional[str] = None
+ self._idp_configuration_id: Optional[str] = None
+
+ return None
+
+ def __str__(self) -> str:
+ str_site_role = self.site_role or "None"
+ return f""
+
+ def __repr__(self):
+ return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}"
@property
- def auth_setting(self):
+ def auth_setting(self) -> Optional[str]:
return self._auth_setting
@auth_setting.setter
@@ -64,73 +161,105 @@ 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
+ @id.setter
+ def id(self, value: str) -> None:
+ self._id = value
+
@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
+ # valid: username, domain/username, username@domain, domain/username@email
+ @staticmethod
+ def validate_username_or_throw(username) -> None:
+ if username is None or username == "" or username.strip(" ") == "":
+ raise AttributeError("Username cannot be empty")
+ if username.find(" ") >= 0:
+ raise AttributeError("Username cannot contain spaces")
+ at_symbol = username.find("@")
+ if at_symbol >= 0:
+ username = username[:at_symbol] + "X" + username[at_symbol + 1 :]
+ if username.find("@") >= 0:
+ raise AttributeError("Username cannot repeat '@'")
+
@property
- def site_role(self):
+ def site_role(self) -> Optional[str]:
return self._site_role
@site_role.setter
- @property_not_nullable
@property_is_enum(Roles)
def site_role(self, value):
self._site_role = value
@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):
- return ResourceReference(id_=self.id, tag_name=self.tag_name)
+ @property
+ def locale(self) -> Optional[str]:
+ return self._locale
- def _set_workbooks(self, workbooks):
+ @property
+ def language(self) -> Optional[str]:
+ return self._language
+
+ @property
+ def idp_configuration_id(self) -> Optional[str]:
+ """
+ IDP configuration id for the user. This is only available on Tableau
+ Cloud, 3.24 or later
+ """
+ return self._idp_configuration_id
+
+ @idp_configuration_id.setter
+ def idp_configuration_id(self, value: str) -> None:
+ self._idp_configuration_id = value
+
+ def _set_workbooks(self, workbooks) -> None:
self._workbooks = workbooks
- 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:
(
_,
@@ -142,8 +271,11 @@ def _parse_common_tags(self, user_xml, ns):
email,
auth_setting,
_,
+ _,
+ _,
+ _,
) = self._parse_element(user_xml, ns)
- self._set_values(None, None, site_role, None, None, fullname, email, auth_setting, None)
+ self._set_values(None, None, site_role, None, None, fullname, email, auth_setting, None, None, None, None)
return self
def _set_values(
@@ -157,6 +289,9 @@ def _set_values(
email,
auth_setting,
domain_name,
+ locale,
+ language,
+ idp_configuration_id,
):
if id is not None:
self._id = id
@@ -176,12 +311,34 @@ def _set_values(
self._auth_setting = auth_setting
if domain_name:
self._domain_name = domain_name
+ if locale:
+ self._locale = locale
+ if language:
+ self._language = language
+ if idp_configuration_id:
+ self._idp_configuration_id = idp_configuration_id
+
+ @classmethod
+ def from_response(cls, resp, ns) -> list["UserItem"]:
+ element_name = ".//t:user"
+ return cls._parse_xml(element_name, resp, ns)
@classmethod
- def from_response(cls, resp, ns):
+ def from_response_as_owner(cls, resp, ns) -> list["UserItem"]:
+ element_name = ".//t:owner"
+ return cls._parse_xml(element_name, resp, ns)
+
+ @classmethod
+ def from_xml(cls, xml: ET.Element, ns: Optional[dict] = None) -> "UserItem":
+ item = cls()
+ item._set_values(*cls._parse_element(xml, ns))
+ return item
+
+ @classmethod
+ def _parse_xml(cls, element_name, resp, ns):
all_user_items = []
- parsed_response = ET.fromstring(resp)
- all_user_xml = parsed_response.findall(".//t:user", namespaces=ns)
+ parsed_response = fromstring(resp)
+ all_user_xml = parsed_response.findall(element_name, namespaces=ns)
for user_xml in all_user_xml:
(
id,
@@ -193,6 +350,9 @@ def from_response(cls, resp, ns):
email,
auth_setting,
domain_name,
+ locale,
+ language,
+ idp_configuration_id,
) = cls._parse_element(user_xml, ns)
user_item = cls(name, site_role)
user_item._set_values(
@@ -205,12 +365,15 @@ def from_response(cls, resp, ns):
email,
auth_setting,
domain_name,
+ locale,
+ language,
+ idp_configuration_id,
)
all_user_items.append(user_item)
return all_user_items
@staticmethod
- def as_reference(id_):
+ def as_reference(id_) -> ResourceReference:
return ResourceReference(id_, UserItem.tag_name)
@staticmethod
@@ -223,6 +386,9 @@ def _parse_element(user_xml, ns):
fullname = user_xml.get("fullName", None)
email = user_xml.get("email", None)
auth_setting = user_xml.get("authSetting", None)
+ locale = user_xml.get("locale", None)
+ language = user_xml.get("language", None)
+ idp_configuration_id = user_xml.get("idpConfigurationId", None)
domain_name = None
domain_elem = user_xml.find(".//t:domain", namespaces=ns)
@@ -239,7 +405,157 @@ def _parse_element(user_xml, ns):
email,
auth_setting,
domain_name,
+ locale,
+ language,
+ idp_configuration_id,
)
- def __repr__(self):
- return "".format(self.id, self.name, self.site_role)
+ class CSVImport:
+ """
+ This class includes hardcoded options and logic for the CSV file format defined for user import
+ https://help.tableau.com/current/server/en-us/users_import.htm
+ """
+
+ # username, password, display_name, license, admin_level, publishing, email, auth type
+ class ColumnType(IntEnum):
+ USERNAME = 0
+ PASS = 1
+ DISPLAY_NAME = 2
+ LICENSE = 3 # aka site role
+ ADMIN = 4
+ PUBLISHER = 5
+ EMAIL = 6
+ AUTH = 7
+
+ MAX = 7
+
+ # Read a csv line and create a user item populated by the given attributes
+ @staticmethod
+ def create_user_from_line(line: str):
+ if line is None or line is False or line == "\n" or line == "":
+ return None
+ line = line.strip().lower()
+ values: list[str] = list(map(str.strip, line.split(",")))
+ user = UserItem(values[UserItem.CSVImport.ColumnType.USERNAME])
+ if len(values) > 1:
+ if len(values) > UserItem.CSVImport.ColumnType.MAX:
+ raise ValueError("Too many attributes for user import")
+ while len(values) <= UserItem.CSVImport.ColumnType.MAX:
+ values.append("")
+ site_role = UserItem.CSVImport._evaluate_site_role(
+ values[UserItem.CSVImport.ColumnType.LICENSE],
+ values[UserItem.CSVImport.ColumnType.ADMIN],
+ values[UserItem.CSVImport.ColumnType.PUBLISHER],
+ )
+
+ user._set_values(
+ None,
+ values[UserItem.CSVImport.ColumnType.USERNAME],
+ site_role,
+ None,
+ None,
+ values[UserItem.CSVImport.ColumnType.DISPLAY_NAME],
+ values[UserItem.CSVImport.ColumnType.EMAIL],
+ values[UserItem.CSVImport.ColumnType.AUTH],
+ None,
+ None,
+ None,
+ None,
+ )
+ return user
+
+ # Read through an entire CSV file meant for user import
+ # Return the number of valid lines and a list of all the invalid lines
+ @staticmethod
+ def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> tuple[int, list[str]]:
+ num_valid_lines = 0
+ invalid_lines = []
+ csv_file.seek(0) # set to start of file in case it has been read earlier
+ line: str = csv_file.readline()
+ while line and line != "":
+ try:
+ # do not print passwords
+ logger.info(f"Reading user {line[:4]}")
+ UserItem.CSVImport._validate_import_line_or_throw(line, logger)
+ num_valid_lines += 1
+ except Exception as exc:
+ logger.info(f"Error parsing {line[:4]}: {exc}")
+ invalid_lines.append(line)
+ line = csv_file.readline()
+ return num_valid_lines, invalid_lines
+
+ # Some fields in the import file are restricted to specific values
+ # Iterate through each field and validate the given value against hardcoded constraints
+ @staticmethod
+ def _validate_import_line_or_throw(incoming, logger) -> None:
+ _valid_attributes: list[list[str]] = [
+ [],
+ [],
+ [],
+ ["creator", "explorer", "viewer", "unlicensed"], # license
+ ["system", "site", "none", "no"], # admin
+ ["yes", "true", "1", "no", "false", "0"], # publisher
+ [],
+ [UserItem.Auth.SAML, UserItem.Auth.OpenID, UserItem.Auth.ServerDefault], # auth
+ ]
+
+ line = list(map(str.strip, incoming.split(",")))
+ if len(line) > UserItem.CSVImport.ColumnType.MAX:
+ raise AttributeError("Too many attributes in line")
+ username = line[UserItem.CSVImport.ColumnType.USERNAME.value]
+ logger.debug(f"> details - {username}")
+ UserItem.validate_username_or_throw(username)
+ for i in range(1, len(line)):
+ logger.debug(f"column {UserItem.CSVImport.ColumnType(i).name}: {line[i]}")
+ UserItem.CSVImport._validate_attribute_value(
+ line[i], _valid_attributes[i], UserItem.CSVImport.ColumnType(i)
+ )
+
+ # Given a restricted set of possible values, confirm the item is in that set
+ @staticmethod
+ def _validate_attribute_value(item: str, possible_values: list[str], column_type) -> None:
+ if item is None or item == "":
+ # value can be empty for any column except user, which is checked elsewhere
+ return
+ if item in possible_values or possible_values == []:
+ return
+ raise AttributeError(f"Invalid value {item} for {column_type}")
+
+ # https://help.tableau.com/current/server/en-us/csvguidelines.htm#settings_and_site_roles
+ # This logic is hardcoded to match the existing rules for import csv files
+ @staticmethod
+ def _evaluate_site_role(license_level, admin_level, publisher):
+ if not license_level or not admin_level or not publisher:
+ return "Unlicensed"
+ # ignore case everywhere
+ license_level = license_level.lower()
+ admin_level = admin_level.lower()
+ publisher = publisher.lower()
+ # don't need to check publisher for system/site admin
+ if admin_level == "system":
+ site_role = "SiteAdministrator"
+ elif admin_level == "site":
+ if license_level == "creator":
+ site_role = "SiteAdministratorCreator"
+ elif license_level == "explorer":
+ site_role = "SiteAdministratorExplorer"
+ else:
+ site_role = "SiteAdministratorExplorer"
+ else: # if it wasn't 'system' or 'site' then we can treat it as 'none'
+ if publisher == "yes":
+ if license_level == "creator":
+ site_role = "Creator"
+ elif license_level == "explorer":
+ site_role = "ExplorerCanPublish"
+ else:
+ site_role = "Unlicensed" # is this the expected outcome?
+ else: # publisher == 'no':
+ if license_level == "explorer" or license_level == "creator":
+ site_role = "Explorer"
+ elif license_level == "viewer":
+ site_role = "Viewer"
+ else: # if license_level == 'unlicensed'
+ site_role = "Unlicensed"
+ if site_role is None:
+ site_role = "Unlicensed"
+ return site_role
diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py
index f18acfc33..dc8eda9c8 100644
--- a/tableauserverclient/models/view_item.py
+++ b/tableauserverclient/models/view_item.py
@@ -1,29 +1,126 @@
-import xml.etree.ElementTree as ET
-from ..datetime_helpers import parse_datetime
-from .exceptions import UnpopulatedPropertyError
-from .tag_item import TagItem
import copy
+from datetime import datetime
+from requests import Response
+from typing import TYPE_CHECKING, Callable, Optional, overload
+from collections.abc import Iterator
+from defusedxml.ElementTree import fromstring
-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()
+from tableauserverclient.datetime_helpers import parse_datetime
+from tableauserverclient.models.exceptions import UnpopulatedPropertyError
+from tableauserverclient.models.location_item import LocationItem
+from tableauserverclient.models.permissions_item import PermissionsRule
+from tableauserverclient.models.project_item import ProjectItem
+from tableauserverclient.models.tag_item import TagItem
+from tableauserverclient.models.user_item import UserItem
+
+if TYPE_CHECKING:
+ from tableauserverclient.models.workbook_item import WorkbookItem
+
+
+class ViewItem:
+ """
+ Contains the members or attributes for the view resources on Tableau Server.
+ The ViewItem class defines the information you can request or query from
+ Tableau Server. The class members correspond to the attributes of a server
+ request or response payload.
+
+ Attributes
+ ----------
+ content_url: Optional[str], default None
+ The name of the view as it would appear in a URL.
+
+ created_at: Optional[datetime], default None
+ The date and time when the view was created.
+
+ id: Optional[str], default None
+ The unique identifier for the view.
+
+ image: Optional[Callable[[], bytes]], default None
+ The image of the view. You must first call the `views.populate_image`
+ method to access the image.
+
+ location: Optional[LocationItem], default None
+ The location of the view. The location can be a personal space or a
+ project.
+
+ name: Optional[str], default None
+ The name of the view.
+
+ owner: Optional[UserItem], default None
+ The owner of the view.
+
+ owner_id: Optional[str], default None
+ The ID for the owner of the view.
+
+ pdf: Optional[Callable[[], bytes]], default None
+ The PDF of the view. You must first call the `views.populate_pdf`
+ method to access the PDF.
+
+ preview_image: Optional[Callable[[], bytes]], default None
+ The preview image of the view. You must first call the
+ `views.populate_preview_image` method to access the preview image.
+
+ project: Optional[ProjectItem], default None
+ The project that contains the view.
+
+ project_id: Optional[str], default None
+ The ID for the project that contains the view.
+
+ tags: set[str], default set()
+ The tags associated with the view.
+
+ total_views: Optional[int], default None
+ The total number of views for the view.
+
+ updated_at: Optional[datetime], default None
+ The date and time when the view was last updated.
+
+ workbook: Optional[WorkbookItem], default None
+ The workbook that contains the view.
+
+ workbook_id: Optional[str], default None
+ The ID for the workbook that contains the view.
+ """
+
+ def __init__(self) -> None:
+ 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[[], Iterator[bytes]]] = None
+ self._excel: Optional[Callable[[], Iterator[bytes]]] = None
+ self._total_views: Optional[int] = None
+ self._sheet_type: Optional[str] = None
+ self._updated_at: Optional[datetime] = None
+ self._workbook_id: Optional[str] = None
+ self._permissions: Optional[Callable[[], list[PermissionsRule]]] = None
+ self.tags: set[str] = set()
+ self._favorites_total: Optional[int] = None
+ self._view_url_name: Optional[str] = None
+ self._data_acceleration_config = {
+ "acceleration_enabled": None,
+ "acceleration_status": None,
+ }
+
+ self._owner: Optional[UserItem] = None
+ self._project: Optional[ProjectItem] = None
+ self._workbook: Optional["WorkbookItem"] = None
+ self._location: Optional[LocationItem] = None
+
+ def __str__(self):
+ return "".format(
+ self._id, self.name, self.content_url, self.project_id
+ )
+
+ def __repr__(self):
+ return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}"
def _set_preview_image(self, preview_image):
self._preview_image = preview_image
@@ -37,60 +134,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) -> Iterator[bytes]:
if self._csv is None:
error = "View item must be populated with its csv first."
raise UnpopulatedPropertyError(error)
return self._csv()
@property
- def sheet_type(self):
+ def excel(self) -> Iterator[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,65 +208,143 @@ 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 view_url_name(self) -> Optional[str]:
+ return self._view_url_name
+
+ @property
+ def favorites_total(self) -> Optional[int]:
+ return self._favorites_total
+
+ @property
+ def data_acceleration_config(self):
+ return self._data_acceleration_config
+
+ @data_acceleration_config.setter
+ def data_acceleration_config(self, value):
+ self._data_acceleration_config = value
+
+ @property
+ def project(self) -> Optional["ProjectItem"]:
+ return self._project
+
+ @property
+ def workbook(self) -> Optional["WorkbookItem"]:
+ return self._workbook
+
+ @property
+ def owner(self) -> Optional[UserItem]:
+ return self._owner
+
+ @property
+ def location(self) -> Optional[LocationItem]:
+ return self._location
+
+ @property
+ def permissions(self) -> list[PermissionsRule]:
if self._permissions is None:
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: "Response", ns, workbook_id="") -> list["ViewItem"]:
+ return cls.from_xml_element(fromstring(resp), ns, workbook_id)
@classmethod
- def from_xml_element(cls, parsed_response, ns, workbook_id=""):
+ def from_xml_element(cls, parsed_response, ns, workbook_id="") -> list["ViewItem"]:
all_view_items = list()
all_view_xml = parsed_response.findall(".//t:view", namespaces=ns)
for view_xml in all_view_xml:
- view_item = cls()
- usage_elem = view_xml.find(".//t:usage", namespaces=ns)
- workbook_elem = view_xml.find(".//t:workbook", namespaces=ns)
- owner_elem = view_xml.find(".//t:owner", namespaces=ns)
- project_elem = view_xml.find(".//t:project", namespaces=ns)
- tags_elem = view_xml.find(".//t:tags", namespaces=ns)
- view_item._created_at = parse_datetime(view_xml.get("createdAt", None))
- view_item._updated_at = parse_datetime(view_xml.get("updatedAt", None))
- view_item._id = view_xml.get("id", None)
- view_item._name = view_xml.get("name", None)
- view_item._content_url = view_xml.get("contentUrl", None)
- view_item._sheet_type = view_xml.get("sheetType", None)
-
- if usage_elem is not None:
- total_view = usage_elem.get("totalViewCount", None)
- if total_view:
- view_item._total_views = int(total_view)
-
- if owner_elem is not None:
- view_item._owner_id = owner_elem.get("id", None)
-
- if project_elem is not None:
- view_item._project_id = project_elem.get("id", None)
-
- if workbook_id:
- view_item._workbook_id = workbook_id
- elif workbook_elem is not None:
- view_item._workbook_id = workbook_elem.get("id", None)
-
- if tags_elem is not None:
- tags = TagItem.from_xml_element(tags_elem, ns)
- view_item.tags = tags
- view_item._initial_tags = copy.copy(tags)
-
+ view_item = cls.from_xml(view_xml, ns, workbook_id)
all_view_items.append(view_item)
return all_view_items
+
+ @classmethod
+ def from_xml(cls, view_xml, ns, workbook_id="") -> "ViewItem":
+ view_item = cls()
+ usage_elem = view_xml.find(".//t:usage", namespaces=ns)
+ workbook_elem = view_xml.find(".//t:workbook", namespaces=ns)
+ owner_elem = view_xml.find(".//t:owner", namespaces=ns)
+ project_elem = view_xml.find(".//t:project", namespaces=ns)
+ tags_elem = view_xml.find("./t:tags", namespaces=ns)
+ data_acceleration_config_elem = view_xml.find(".//t:dataAccelerationConfig", namespaces=ns)
+ view_item._created_at = parse_datetime(view_xml.get("createdAt", None))
+ view_item._updated_at = parse_datetime(view_xml.get("updatedAt", None))
+ view_item._id = view_xml.get("id", None)
+ view_item._name = view_xml.get("name", None)
+ view_item._content_url = view_xml.get("contentUrl", None)
+ view_item._sheet_type = view_xml.get("sheetType", None)
+ view_item._favorites_total = string_to_int(view_xml.get("favoritesTotal", None))
+ view_item._view_url_name = view_xml.get("viewUrlName", None)
+ if usage_elem is not None:
+ total_view = usage_elem.get("totalViewCount", None)
+ if total_view:
+ view_item._total_views = int(total_view)
+ if owner_elem is not None:
+ user = UserItem.from_xml(owner_elem, ns)
+ view_item._owner = user
+ view_item._owner_id = owner_elem.get("id", None)
+ if project_elem is not None:
+ project_item = ProjectItem.from_xml(project_elem, ns)
+ view_item._project = project_item
+ view_item._project_id = project_item.id
+ if workbook_id:
+ view_item._workbook_id = workbook_id
+ elif workbook_elem is not None:
+ from tableauserverclient.models.workbook_item import WorkbookItem
+
+ workbook_item = WorkbookItem.from_xml(workbook_elem, ns)
+ view_item._workbook = workbook_item
+ view_item._workbook_id = workbook_item.id
+ if tags_elem is not None:
+ tags = TagItem.from_xml_element(tags_elem, ns)
+ view_item.tags = tags
+ view_item._initial_tags = copy.copy(tags)
+ if (location_elem := view_xml.find(".//t:location", namespaces=ns)) is not None:
+ location = LocationItem.from_xml(location_elem, ns)
+ view_item._location = location
+ if data_acceleration_config_elem is not None:
+ data_acceleration_config = parse_data_acceleration_config(data_acceleration_config_elem)
+ view_item.data_acceleration_config = data_acceleration_config
+ return view_item
+
+
+def parse_data_acceleration_config(data_acceleration_elem):
+ data_acceleration_config = dict()
+
+ acceleration_enabled = data_acceleration_elem.get("accelerationEnabled", None)
+ if acceleration_enabled is not None:
+ acceleration_enabled = string_to_bool(acceleration_enabled)
+
+ acceleration_status = data_acceleration_elem.get("accelerationStatus", None)
+
+ data_acceleration_config["acceleration_enabled"] = acceleration_enabled
+ data_acceleration_config["acceleration_status"] = acceleration_status
+ return data_acceleration_config
+
+
+def string_to_bool(s: str) -> bool:
+ return s.lower() == "true"
+
+
+@overload
+def string_to_int(s: None) -> None: ...
+
+
+@overload
+def string_to_int(s: str) -> int: ...
+
+
+def string_to_int(s):
+ return int(s) if s is not None else None
diff --git a/tableauserverclient/models/virtual_connection_item.py b/tableauserverclient/models/virtual_connection_item.py
new file mode 100644
index 000000000..e9e22be1e
--- /dev/null
+++ b/tableauserverclient/models/virtual_connection_item.py
@@ -0,0 +1,78 @@
+import datetime as dt
+import json
+from typing import Callable, Optional
+from collections.abc import Iterable
+from xml.etree.ElementTree import Element
+
+from defusedxml.ElementTree import fromstring
+
+from tableauserverclient.datetime_helpers import parse_datetime
+from tableauserverclient.models.connection_item import ConnectionItem
+from tableauserverclient.models.exceptions import UnpopulatedPropertyError
+from tableauserverclient.models.permissions_item import PermissionsRule
+
+
+class VirtualConnectionItem:
+ def __init__(self, name: str) -> None:
+ self.name = name
+ self.created_at: Optional[dt.datetime] = None
+ self.has_extracts: Optional[bool] = None
+ self._id: Optional[str] = None
+ self.is_certified: Optional[bool] = None
+ self.updated_at: Optional[dt.datetime] = None
+ self.webpage_url: Optional[str] = None
+ self._connections: Optional[Callable[[], Iterable[ConnectionItem]]] = None
+ self.project_id: Optional[str] = None
+ self.owner_id: Optional[str] = None
+ self.content: Optional[dict[str, dict]] = None
+ self.certification_note: Optional[str] = None
+
+ def __str__(self) -> str:
+ return f"{self.__class__.__qualname__}(name={self.name})"
+
+ def __repr__(self) -> str:
+ return f"<{self!s}>"
+
+ def _set_permissions(self, permissions):
+ self._permissions = permissions
+
+ @property
+ def id(self) -> Optional[str]:
+ return self._id
+
+ @property
+ def permissions(self) -> list[PermissionsRule]:
+ if self._permissions is None:
+ error = "Workbook item must be populated with permissions first."
+ raise UnpopulatedPropertyError(error)
+ return self._permissions()
+
+ @property
+ def connections(self) -> Iterable[ConnectionItem]:
+ if self._connections is None:
+ raise AttributeError("connections not populated. Call populate_connections() first.")
+ return self._connections()
+
+ @classmethod
+ def from_response(cls, response: bytes, ns: dict[str, str]) -> list["VirtualConnectionItem"]:
+ parsed_response = fromstring(response)
+ return [cls.from_xml(xml, ns) for xml in parsed_response.findall(".//t:virtualConnection[@name]", ns)]
+
+ @classmethod
+ def from_xml(cls, xml: Element, ns: dict[str, str]) -> "VirtualConnectionItem":
+ v_conn = cls(xml.get("name", ""))
+ v_conn._id = xml.get("id", None)
+ v_conn.webpage_url = xml.get("webpageUrl", None)
+ v_conn.created_at = parse_datetime(xml.get("createdAt", None))
+ v_conn.updated_at = parse_datetime(xml.get("updatedAt", None))
+ v_conn.is_certified = string_to_bool(s) if (s := xml.get("isCertified", None)) else None
+ v_conn.certification_note = xml.get("certificationNote", None)
+ v_conn.has_extracts = string_to_bool(s) if (s := xml.get("hasExtracts", None)) else None
+ v_conn.project_id = p.get("id", None) if ((p := xml.find(".//t:project[@id]", ns)) is not None) else None
+ v_conn.owner_id = o.get("id", None) if ((o := xml.find(".//t:owner[@id]", ns)) is not None) else None
+ v_conn.content = json.loads(c.text or "{}") if ((c := xml.find(".//t:content", ns)) is not None) else None
+ return v_conn
+
+
+def string_to_bool(s: str) -> bool:
+ return s.lower() in ["true", "1", "t", "y", "yes"]
diff --git a/tableauserverclient/models/webhook_item.py b/tableauserverclient/models/webhook_item.py
index 5fc5c5749..8b551dea4 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 Optional
+from defusedxml.ElementTree import fromstring
NAMESPACE_RE = re.compile(r"^{.*}")
@@ -12,13 +13,46 @@ def _parse_event(events):
return NAMESPACE_RE.sub("", event.tag)
-class WebhookItem(object):
+class WebhookItem:
+ """
+ The WebhookItem represents the webhook resources on Tableau Server or
+ Tableau Cloud. This is the information that can be sent or returned in
+ response to a REST API request for webhooks.
+
+ Attributes
+ ----------
+ id : Optional[str]
+ The identifier (luid) for the webhook. You need this value to query a
+ specific webhook with the get_by_id method or to delete a webhook with
+ the delete method.
+
+ name : Optional[str]
+ The name of the webhook. You must specify this when you create an
+ instance of the WebhookItem.
+
+ url : Optional[str]
+ The destination URL for the webhook. The webhook destination URL must
+ be https and have a valid certificate. You must specify this when you
+ create an instance of the WebhookItem.
+
+ event : Optional[str]
+ The name of the Tableau event that triggers your webhook.This is either
+ api-event-name or webhook-source-api-event-name: one of these is
+ required to create an instance of the WebhookItem. We recommend using
+ the api-event-name. The event name must be one of the supported events
+ listed in the Trigger Events table.
+ https://help.tableau.com/current/developer/webhooks/en-us/docs/webhooks-events-payload.html
+
+ owner_id : Optional[str]
+ The identifier (luid) of the user who owns the webhook.
+ """
+
def __init__(self):
- self._id = 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 +67,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):
- self._event = "webhook-source-event-{}".format(value)
+ def event(self, value: str) -> None:
+ self._event = f"webhook-source-event-{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 +94,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 +114,5 @@ def _parse_element(webhook_xml, ns):
return id, name, url, event, owner_id
- def __repr__(self):
- return "".format(self.id, self.name, self.url, self.event)
+ def __repr__(self) -> str:
+ return f""
diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py
index 9c7e2022e..a3ede65d6 100644
--- a/tableauserverclient/models/workbook_item.py
+++ b/tableauserverclient/models/workbook_item.py
@@ -1,114 +1,266 @@
+import copy
+import datetime
+import uuid
import xml.etree.ElementTree as ET
+from typing import Callable, Optional, overload
+
+from defusedxml.ElementTree import fromstring
+
+from tableauserverclient.datetime_helpers import parse_datetime
+from tableauserverclient.models.location_item import LocationItem
+from tableauserverclient.models.project_item import ProjectItem
+from tableauserverclient.models.user_item import UserItem
+from .connection_item import ConnectionItem
from .exceptions import UnpopulatedPropertyError
+from .permissions_item import PermissionsRule
from .property_decorators import (
- property_not_nullable,
property_is_boolean,
property_is_data_acceleration_config,
)
+from .revision_item import RevisionItem
from .tag_item import TagItem
from .view_item import ViewItem
-from .permissions_item import PermissionsRule
-from ..datetime_helpers import parse_datetime
-import copy
-import uuid
+from .data_freshness_policy_item import DataFreshnessPolicyItem
+
+
+class WorkbookItem:
+ """
+ The workbook resources for Tableau are defined in the WorkbookItem class.
+ The class corresponds to the workbook resources you can access using the
+ Tableau REST API. Some workbook methods take an instance of the WorkbookItem
+ class as arguments. The workbook item specifies the project.
+
+ Parameters
+ ----------
+ project_id : Optional[str], optional
+ The project ID for the workbook, by default None.
+
+ name : Optional[str], optional
+ The name of the workbook, by default None.
+
+ show_tabs : bool, optional
+ Determines whether the workbook shows tabs for the view.
+
+ Attributes
+ ----------
+ connections : list[ConnectionItem]
+ The list of data connections (ConnectionItem) for the data sources used
+ by the workbook. You must first call the workbooks.populate_connections
+ method to access this data. See the ConnectionItem class.
+
+ content_url : Optional[str]
+ The name of the workbook as it appears in the URL.
+
+ created_at : Optional[datetime.datetime]
+ The date and time the workbook was created.
+
+ default_view_id : Optional[str]
+ The identifier for the default view of the workbook.
+
+ description : Optional[str]
+ User-defined description of the workbook.
+
+ encrypt_extracts : Optional[bool]
+ Indicates whether extracts are encrypted.
+
+ has_extracts : Optional[bool]
+ Indicates whether the workbook has extracts.
+
+ id : Optional[str]
+ The identifier for the workbook. You need this value to query a specific
+ workbook or to delete a workbook with the get_by_id and delete methods.
+
+ last_published_at : Optional[datetime.datetime]
+ The date and time the workbook was last published.
+
+ location : Optional[LocationItem]
+ The location of the workbook, such as a personal space or project.
+
+ owner : Optional[UserItem]
+ The owner of the workbook.
+
+ owner_id : Optional[str]
+ The identifier for the owner (UserItem) of the workbook.
+
+ preview_image : bytes
+ The thumbnail image for the view. You must first call the
+ workbooks.populate_preview_image method to access this data.
+
+ project: Optional[ProjectItem]
+ The project that contains the workbook.
+ project_name : Optional[str]
+ The name of the project that contains the workbook.
-class WorkbookItem(object):
- def __init__(self, project_id, name=None, show_tabs=False):
+ size: int
+ The size of the workbook in megabytes.
+
+ hidden_views: Optional[list[str]]
+ List of string names of views that need to be hidden when the workbook
+ is published.
+
+ tags: set[str]
+ The set of tags associated with the workbook.
+
+ updated_at : Optional[datetime.datetime]
+ The date and time the workbook was last updated.
+
+ views : list[ViewItem]
+ The list of views (ViewItem) for the workbook. You must first call the
+ workbooks.populate_views method to access this data. See the ViewItem
+ class.
+
+ web_page_url : Optional[str]
+ The full URL for the workbook.
+
+ Examples
+ --------
+ # creating a new instance of a WorkbookItem
+ >>> import tableauserverclient as TSC
+
+ >>> # Create new workbook_item with project id '3a8b6148-493c-11e6-a621-6f3499394a39'
+
+ >>> new_workbook = TSC.WorkbookItem('3a8b6148-493c-11e6-a621-6f3499394a39')
+ """
+
+ def __init__(
+ self,
+ project_id: Optional[str] = None,
+ name: Optional[str] = None,
+ show_tabs: bool = False,
+ thumbnails_user_id: Optional[str] = None,
+ thumbnails_group_id: Optional[str] = None,
+ ) -> 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._views: Optional[Callable[[], list[ViewItem]]] = None
self.name = name
self._description = None
- self.owner_id = None
- self.project_id = project_id
+ self.owner_id: Optional[str] = None
+ # workaround for Personal Space workbooks without a project
+ self.project_id: Optional[str] = project_id or uuid.uuid4().__str__()
self.show_tabs = show_tabs
- self.tags = set()
+ self.hidden_views: Optional[list[str]] = None
+ self.tags: set[str] = set()
self.data_acceleration_config = {
"acceleration_enabled": None,
"accelerate_now": None,
"last_updated_at": None,
"acceleration_status": None,
}
+ self.data_freshness_policy = None
self._permissions = None
+ self.thumbnails_user_id = thumbnails_user_id
+ self.thumbnails_group_id = thumbnails_group_id
+ self._sheet_count: Optional[int] = None
+ self._has_extracts: Optional[bool] = None
+ self._project: Optional[ProjectItem] = None
+ self._owner: Optional[UserItem] = None
+ self._location: Optional[LocationItem] = None
+ self._encrypt_extracts: Optional[bool] = None
+ self._default_view_id: Optional[str] = None
+ self._share_description: Optional[str] = None
+ self._last_published_at: Optional[datetime.datetime] = None
+
+ return None
+
+ def __str__(self):
+ return "".format(
+ self._id, self.name, self.content_url, self.project_id
+ )
+
+ def __repr__(self):
+ return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}"
@property
- def connections(self):
+ 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
+ @description.setter
+ def description(self, value: str):
+ self._description = value
+
@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 +268,19 @@ def size(self):
return self._size
@property
- def updated_at(self):
+ def sheet_count(self) -> Optional[int]:
+ return self._sheet_count
+
+ @property
+ def has_extracts(self) -> Optional[bool]:
+ return self._has_extracts
+
+ @property
+ def updated_at(self) -> Optional[datetime.datetime]:
return self._updated_at
@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
@@ -136,6 +296,10 @@ def views(self):
# We had views included in a WorkbookItem response
return self._views
+ @views.setter
+ def views(self, value):
+ self._views = value
+
@property
def data_acceleration_config(self):
return self._data_acceleration_config
@@ -145,24 +309,90 @@ def data_acceleration_config(self):
def data_acceleration_config(self, value):
self._data_acceleration_config = value
+ @property
+ def data_freshness_policy(self):
+ return self._data_freshness_policy
+
+ @data_freshness_policy.setter
+ # @property_is_data_freshness_policy
+ def data_freshness_policy(self, value):
+ self._data_freshness_policy = value
+
+ @property
+ def revisions(self) -> list[RevisionItem]:
+ if self._revisions is None:
+ error = "Workbook item must be populated with revisions first."
+ raise UnpopulatedPropertyError(error)
+ return self._revisions()
+
+ @property
+ def thumbnails_user_id(self) -> Optional[str]:
+ return self._thumbnails_user_id
+
+ @thumbnails_user_id.setter
+ def thumbnails_user_id(self, value: str):
+ self._thumbnails_user_id = value
+
+ @property
+ def thumbnails_group_id(self) -> Optional[str]:
+ return self._thumbnails_group_id
+
+ @thumbnails_group_id.setter
+ def thumbnails_group_id(self, value: str):
+ self._thumbnails_group_id = value
+
+ @property
+ def project(self) -> Optional[ProjectItem]:
+ return self._project
+
+ @property
+ def owner(self) -> Optional[UserItem]:
+ return self._owner
+
+ @property
+ def location(self) -> Optional[LocationItem]:
+ return self._location
+
+ @property
+ def encrypt_extracts(self) -> Optional[bool]:
+ return self._encrypt_extracts
+
+ @property
+ def default_view_id(self) -> Optional[str]:
+ return self._default_view_id
+
+ @property
+ def share_description(self) -> Optional[str]:
+ return self._share_description
+
+ @property
+ def last_published_at(self) -> Optional[datetime.datetime]:
+ return self._last_published_at
+
def _set_connections(self, connections):
self._connections = connections
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:
(
_,
@@ -178,8 +408,18 @@ def _parse_common_tags(self, workbook_xml, ns):
project_name,
owner_id,
_,
- _,
+ views,
data_acceleration_config,
+ data_freshness_policy,
+ sheet_count,
+ has_extracts,
+ project,
+ owner,
+ location,
+ encrypt_extracts,
+ default_view_id,
+ share_description,
+ last_published_at,
) = self._parse_element(workbook_xml, ns)
self._set_values(
@@ -196,8 +436,18 @@ def _parse_common_tags(self, workbook_xml, ns):
project_name,
owner_id,
None,
- None,
+ views,
data_acceleration_config,
+ data_freshness_policy,
+ sheet_count,
+ has_extracts,
+ project,
+ owner,
+ location,
+ encrypt_extracts,
+ default_view_id,
+ share_description,
+ last_published_at,
)
return self
@@ -219,6 +469,16 @@ def _set_values(
tags,
views,
data_acceleration_config,
+ data_freshness_policy,
+ sheet_count,
+ has_extracts,
+ project,
+ owner,
+ location,
+ encrypt_extracts,
+ default_view_id,
+ share_description,
+ last_published_at,
):
if id is not None:
self._id = id
@@ -247,60 +507,47 @@ def _set_values(
if tags:
self.tags = tags
self._initial_tags = copy.copy(tags)
- if views:
+ if views is not None:
self._views = views
if data_acceleration_config is not None:
self.data_acceleration_config = data_acceleration_config
+ if data_freshness_policy is not None:
+ self.data_freshness_policy = data_freshness_policy
+ if sheet_count is not None:
+ self._sheet_count = sheet_count
+ if has_extracts is not None:
+ self._has_extracts = has_extracts
+ if project:
+ self._project = project
+ if owner:
+ self._owner = owner
+ if location:
+ self._location = location
+ if encrypt_extracts is not None:
+ self._encrypt_extracts = encrypt_extracts
+ if default_view_id is not None:
+ self._default_view_id = default_view_id
+ if share_description is not None:
+ self._share_description = share_description
+ if last_published_at is not None:
+ self._last_published_at = last_published_at
@classmethod
- def from_response(cls, resp, 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:
- (
- id,
- name,
- content_url,
- webpage_url,
- created_at,
- description,
- updated_at,
- size,
- show_tabs,
- project_id,
- project_name,
- owner_id,
- tags,
- views,
- data_acceleration_config,
- ) = cls._parse_element(workbook_xml, ns)
-
- # workaround for Personal Space workbooks which won't have a project
- if not project_id:
- project_id = uuid.uuid4()
-
- workbook_item = cls(project_id)
- workbook_item._set_values(
- id,
- name,
- content_url,
- webpage_url,
- created_at,
- description,
- updated_at,
- size,
- show_tabs,
- None,
- project_name,
- owner_id,
- tags,
- views,
- data_acceleration_config,
- )
+ workbook_item = cls.from_xml(workbook_xml, ns)
all_workbook_items.append(workbook_item)
return all_workbook_items
+ @classmethod
+ def from_xml(cls, workbook_xml, ns):
+ workbook_item = cls()
+ workbook_item._set_values(*cls._parse_element(workbook_xml, ns))
+ return workbook_item
+
@staticmethod
def _parse_element(workbook_xml, ns):
id = workbook_xml.get("id", None)
@@ -310,6 +557,12 @@ def _parse_element(workbook_xml, ns):
created_at = parse_datetime(workbook_xml.get("createdAt", None))
description = workbook_xml.get("description", None)
updated_at = parse_datetime(workbook_xml.get("updatedAt", None))
+ sheet_count = string_to_int(workbook_xml.get("sheetCount", None))
+ has_extracts = string_to_bool(workbook_xml.get("hasExtracts", ""))
+ encrypt_extracts = string_to_bool(e) if (e := workbook_xml.get("encryptExtracts", None)) is not None else None
+ default_view_id = workbook_xml.get("defaultViewId", None)
+ share_description = workbook_xml.get("shareDescription", None)
+ last_published_at = parse_datetime(workbook_xml.get("lastPublishedAt", None))
size = workbook_xml.get("size", None)
if size:
@@ -319,14 +572,18 @@ def _parse_element(workbook_xml, ns):
project_id = None
project_name = None
+ project = None
project_tag = workbook_xml.find(".//t:project", namespaces=ns)
if project_tag is not None:
+ project = ProjectItem.from_xml(project_tag, ns)
project_id = project_tag.get("id", None)
project_name = project_tag.get("name", None)
owner_id = None
+ owner = None
owner_tag = workbook_xml.find(".//t:owner", namespaces=ns)
if owner_tag is not None:
+ owner = UserItem.from_xml(owner_tag, ns)
owner_id = owner_tag.get("id", None)
tags = None
@@ -340,6 +597,11 @@ def _parse_element(workbook_xml, ns):
if views_elem is not None:
views = ViewItem.from_xml_element(views_elem, ns)
+ location = None
+ location_elem = workbook_xml.find(".//t:location", namespaces=ns)
+ if location_elem is not None:
+ location = LocationItem.from_xml(location_elem, ns)
+
data_acceleration_config = {
"acceleration_enabled": None,
"accelerate_now": None,
@@ -350,6 +612,11 @@ def _parse_element(workbook_xml, ns):
if data_acceleration_elem is not None:
data_acceleration_config = parse_data_acceleration_config(data_acceleration_elem)
+ data_freshness_policy = None
+ data_freshness_policy_elem = workbook_xml.find(".//t:dataFreshnessPolicy", namespaces=ns)
+ if data_freshness_policy_elem is not None:
+ data_freshness_policy = DataFreshnessPolicyItem.from_xml_element(data_freshness_policy_elem, ns)
+
return (
id,
name,
@@ -366,6 +633,16 @@ def _parse_element(workbook_xml, ns):
tags,
views,
data_acceleration_config,
+ data_freshness_policy,
+ sheet_count,
+ has_extracts,
+ project,
+ owner,
+ location,
+ encrypt_extracts,
+ default_view_id,
+ share_description,
+ last_published_at,
)
@@ -394,5 +671,17 @@ 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"
+
+
+@overload
+def string_to_int(s: None) -> None: ...
+
+
+@overload
+def string_to_int(s: str) -> int: ...
+
+
+def string_to_int(s):
+ return int(s) if s is not None else None
diff --git a/tableauserverclient/namespace.py b/tableauserverclient/namespace.py
index 986a02fb3..54ac46d8d 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"\{(.*?)\}")
@@ -10,7 +11,7 @@ class UnknownNamespaceError(Exception):
pass
-class Namespace(object):
+class Namespace:
def __init__(self):
self._namespace = {"t": NEW_NAMESPACE}
self._detected = False
@@ -25,7 +26,7 @@ def detect(self, xml):
if not xml.startswith(b" str:
+ return f"{self.parent_srv.baseurl}/auth"
@api(version="2.0")
- def sign_in(self, auth_req):
- url = "{0}/{1}".format(self.baseurl, "signin")
+ def sign_in(self, auth_req: "Credentials") -> contextmgr:
+ """
+ Sign in to a Tableau Server or Tableau Online using a credentials object.
+
+ The credentials object can either be a TableauAuth object, a
+ PersonalAccessTokenAuth object, or a JWTAuth object. This method now
+ accepts them all. The object should be populated with the site_id and
+ optionally a user_id to impersonate.
+
+ Creates a context manager that will sign out of the server upon exit.
+
+ Parameters
+ ----------
+ auth_req : Credentials
+ The credentials object to use for signing in. Can be a TableauAuth,
+ PersonalAccessTokenAuth, or JWTAuth object.
+
+ Returns
+ -------
+ contextmgr
+ A context manager that will sign out of the server upon exit.
+
+ Examples
+ --------
+ >>> import tableauserverclient as TSC
+
+ >>> # create an auth object
+ >>> tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD')
+
+ >>> # create an instance for your server
+ >>> server = TSC.Server('https://SERVER_URL')
+
+ >>> # call the sign-in method with the auth object
+ >>> server.auth.sign_in(tableau_auth)
+ """
+ url = f"{self.baseurl}/signin"
signin_req = RequestFactory.Auth.signin_req(auth_req)
- server_response = self.parent_srv.session.post(url, data=signin_req, **self.parent_srv.http_options)
+ server_response = self.parent_srv.session.post(
+ url, data=signin_req, **self.parent_srv.http_options, allow_redirects=False
+ )
+ # manually handle a redirect so that we send the correct POST request instead of GET
+ # this will make e.g http://online.tableau.com work to redirect to http://east.online.tableau.com
+ if server_response.status_code == 301:
+ server_response = self.parent_srv.session.post(
+ server_response.headers["Location"],
+ data=signin_req,
+ **self.parent_srv.http_options,
+ allow_redirects=False,
+ )
self.parent_srv._namespace.detect(server_response.content)
- self._check_status(server_response)
- parsed_response = ET.fromstring(server_response.content)
+ self._check_status(server_response, url)
+ parsed_response = fromstring(server_response.content)
site_id = parsed_response.find(".//t:site", namespaces=self.parent_srv.namespace).get("id", None)
+ site_url = parsed_response.find(".//t:site", namespaces=self.parent_srv.namespace).get("contentUrl", None)
user_id = parsed_response.find(".//t:user", namespaces=self.parent_srv.namespace).get("id", None)
auth_token = parsed_response.find("t:credentials", namespaces=self.parent_srv.namespace).get("token", None)
- self.parent_srv._set_auth(site_id, user_id, auth_token)
- logger.info("Signed into {0} as user with id {1}".format(self.parent_srv.server_address, user_id))
+ self.parent_srv._set_auth(site_id, user_id, auth_token, site_url)
+ logger.info(f"Signed into {self.parent_srv.server_address} as user with id {user_id}")
return Auth.contextmgr(self.sign_out)
+ # We use the same request that username/password login uses for all auth types.
+ # The distinct methods are mostly useful for explicitly showing api version support for each auth type
@api(version="3.6")
- def sign_in_with_personal_access_token(self, auth_req):
- # We use the same request that username/password login uses.
+ def sign_in_with_personal_access_token(self, auth_req: "Credentials") -> contextmgr:
+ """Passthrough to sign_in method"""
+ return self.sign_in(auth_req)
+
+ @api(version="3.17")
+ def sign_in_with_json_web_token(self, auth_req: "Credentials") -> contextmgr:
+ """Passthrough to sign_in method"""
return self.sign_in(auth_req)
@api(version="2.0")
- def sign_out(self):
- url = "{0}/{1}".format(self.baseurl, "signout")
+ def sign_out(self) -> None:
+ """Sign out of current session."""
+ url = f"{self.baseurl}/signout"
# If there are no auth tokens you're already signed out. No-op
if not self.parent_srv.is_signed_in():
return
@@ -53,8 +115,35 @@ def sign_out(self):
logger.info("Signed out")
@api(version="2.6")
- def switch_site(self, site_item):
- url = "{0}/{1}".format(self.baseurl, "switchSite")
+ def switch_site(self, site_item: "SiteItem") -> contextmgr:
+ """
+ Switch to a different site on the server. This will sign out of the
+ current site and sign in to the new site. If used as a context manager,
+ will sign out of the new site upon exit.
+
+ Parameters
+ ----------
+ site_item : SiteItem
+ The site to switch to.
+
+ Returns
+ -------
+ contextmgr
+ A context manager that will sign out of the new site upon exit.
+
+ Examples
+ --------
+ >>> import tableauserverclient as TSC
+
+ >>> # Find the site you want to switch to
+ >>> new_site = server.sites.get_by_id("9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d")
+ >>> # switch to the new site
+ >>> with server.auth.switch_site(new_site):
+ >>> # do something on the new site
+ >>> pass
+
+ """
+ url = f"{self.baseurl}/switchSite"
switch_req = RequestFactory.Auth.switch_req(site_item.content_url)
try:
server_response = self.post_request(url, switch_req)
@@ -64,17 +153,21 @@ def switch_site(self, site_item):
else:
raise e
self.parent_srv._namespace.detect(server_response.content)
- self._check_status(server_response)
- parsed_response = ET.fromstring(server_response.content)
+ self._check_status(server_response, url)
+ parsed_response = fromstring(server_response.content)
site_id = parsed_response.find(".//t:site", namespaces=self.parent_srv.namespace).get("id", None)
+ site_url = parsed_response.find(".//t:site", namespaces=self.parent_srv.namespace).get("contentUrl", None)
user_id = parsed_response.find(".//t:user", namespaces=self.parent_srv.namespace).get("id", None)
auth_token = parsed_response.find("t:credentials", namespaces=self.parent_srv.namespace).get("token", None)
- self.parent_srv._set_auth(site_id, user_id, auth_token)
- logger.info("Signed into {0} as user with id {1}".format(self.parent_srv.server_address, user_id))
+ self.parent_srv._set_auth(site_id, user_id, auth_token, site_url)
+ logger.info(f"Signed into {self.parent_srv.server_address} as user with id {user_id}")
return Auth.contextmgr(self.sign_out)
@api(version="3.10")
- def revoke_all_server_admin_tokens(self):
- url = "{0}/{1}".format(self.baseurl, "revokeAllServerAdminTokens")
+ def revoke_all_server_admin_tokens(self) -> None:
+ """
+ Revokes all personal access tokens for all server admins on the server.
+ """
+ url = f"{self.baseurl}/revokeAllServerAdminTokens"
self.post_request(url, "")
logger.info("Revoked all tokens for all server admins")
diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py
new file mode 100644
index 000000000..8d78dca7a
--- /dev/null
+++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py
@@ -0,0 +1,394 @@
+import io
+import logging
+import os
+from contextlib import closing
+from pathlib import Path
+from typing import Optional, Union, TYPE_CHECKING
+from collections.abc import Iterator
+
+from tableauserverclient.config import BYTES_PER_MB, config
+from tableauserverclient.filesys_helpers import get_file_object_size
+from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api
+from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError
+from tableauserverclient.models import CustomViewItem, PaginationItem
+from tableauserverclient.server import (
+ RequestFactory,
+ RequestOptions,
+ ImageRequestOptions,
+ PDFRequestOptions,
+ CSVRequestOptions,
+)
+
+from tableauserverclient.helpers.logging import logger
+
+if TYPE_CHECKING:
+ from tableauserverclient.server.query import QuerySet
+
+"""
+Get a list of custom views on a site
+get the details of a custom view
+download an image of a custom view.
+Delete a custom view
+update the name or owner of a custom view.
+"""
+
+FilePath = Union[str, os.PathLike]
+FileObject = Union[io.BufferedReader, io.BytesIO]
+FileObjectR = Union[io.BufferedReader, io.BytesIO]
+FileObjectW = Union[io.BufferedWriter, io.BytesIO]
+PathOrFileR = Union[FilePath, FileObjectR]
+PathOrFileW = Union[FilePath, FileObjectW]
+io_types_r = (io.BufferedReader, io.BytesIO)
+io_types_w = (io.BufferedWriter, io.BytesIO)
+
+
+class CustomViews(QuerysetEndpoint[CustomViewItem]):
+ def __init__(self, parent_srv):
+ super().__init__(parent_srv)
+
+ @property
+ def baseurl(self) -> str:
+ return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/customviews"
+
+ @property
+ def expurl(self) -> str:
+ return f"{self.parent_srv._server_address}/api/exp/sites/{self.parent_srv.site_id}/customviews"
+
+ @api(version="3.18")
+ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[CustomViewItem], PaginationItem]:
+ """
+ Get a list of custom views on a site.
+
+ If the request has no filter parameters: Administrators will see all custom views.
+ Other users will see only custom views that they own.
+ If the filter parameters include ownerId: Users will see only custom views that they own.
+ If the filter parameters include viewId and/or workbookId, and don't include ownerId:
+ Users will see those custom views that they have Write and WebAuthoring permissions for.
+ If site user visibility is not set to Limited, the Users will see those custom views that are "public",
+ meaning the value of their shared attribute is true.
+ If site user visibility is set to Limited, ????
+
+ Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#list_custom_views
+
+ Parameters
+ ----------
+ req_options : RequestOptions, optional
+ Filtering options for the request, by default None
+
+ Returns
+ -------
+ tuple[list[CustomViewItem], PaginationItem]
+ """
+ logger.info("Querying all custom views on site")
+ url = self.baseurl
+ server_response = self.get_request(url, req_options)
+ pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace)
+ all_view_items = CustomViewItem.list_from_response(server_response.content, self.parent_srv.namespace)
+ return all_view_items, pagination_item
+
+ @api(version="3.18")
+ def get_by_id(self, view_id: str) -> Optional[CustomViewItem]:
+ """
+ Get the details of a specific custom view.
+
+ Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#get_custom_view
+
+ Parameters
+ ----------
+ view_id : str
+
+ Returns
+ -------
+ Optional[CustomViewItem]
+ """
+ if not view_id:
+ error = "Custom view item missing ID."
+ raise MissingRequiredFieldError(error)
+ logger.info(f"Querying custom view (ID: {view_id})")
+ url = f"{self.baseurl}/{view_id}"
+ server_response = self.get_request(url)
+ return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace)
+
+ @api(version="3.18")
+ def populate_image(self, view_item: CustomViewItem, req_options: Optional["ImageRequestOptions"] = None) -> None:
+ """
+ Populate the image of a custom view.
+
+ Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#get_custom_view_image
+
+ Parameters
+ ----------
+ view_item : CustomViewItem
+
+ req_options : ImageRequestOptions, optional
+ Options to customize the image returned, by default None
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ MissingRequiredFieldError
+ If the view_item is missing an ID
+ """
+ if not view_item.id:
+ error = "Custom View item missing ID."
+ raise MissingRequiredFieldError(error)
+
+ def image_fetcher():
+ return self._get_view_image(view_item, req_options)
+
+ view_item._set_image(image_fetcher)
+ logger.info(f"Populated image for custom view (ID: {view_item.id})")
+
+ def _get_view_image(self, view_item: CustomViewItem, req_options: Optional["ImageRequestOptions"]) -> bytes:
+ url = f"{self.baseurl}/{view_item.id}/image"
+ server_response = self.get_request(url, req_options)
+ image = server_response.content
+ return image
+
+ @api(version="3.23")
+ def populate_pdf(self, custom_view_item: CustomViewItem, req_options: Optional["PDFRequestOptions"] = None) -> None:
+ """
+ Populate the PDF of a custom view.
+
+ Parameters
+ ----------
+ custom_view_item : CustomViewItem
+ The custom view item to populate the PDF for.
+
+ req_options : PDFRequestOptions, optional
+ Options to customize the PDF returned, by default None
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ MissingRequiredFieldError
+ If the custom view item is missing an ID
+ """
+ if not custom_view_item.id:
+ error = "Custom View item missing ID."
+ raise MissingRequiredFieldError(error)
+
+ def pdf_fetcher():
+ return self._get_custom_view_pdf(custom_view_item, req_options)
+
+ custom_view_item._set_pdf(pdf_fetcher)
+ logger.info(f"Populated pdf for custom view (ID: {custom_view_item.id})")
+
+ def _get_custom_view_pdf(
+ self, custom_view_item: CustomViewItem, req_options: Optional["PDFRequestOptions"]
+ ) -> bytes:
+ url = f"{self.baseurl}/{custom_view_item.id}/pdf"
+ server_response = self.get_request(url, req_options)
+ pdf = server_response.content
+ return pdf
+
+ @api(version="3.23")
+ def populate_csv(self, custom_view_item: CustomViewItem, req_options: Optional["CSVRequestOptions"] = None) -> None:
+ """
+ Populate the CSV of a custom view.
+
+ Parameters
+ ----------
+ custom_view_item : CustomViewItem
+ The custom view item to populate the CSV for.
+
+ req_options : CSVRequestOptions, optional
+ Options to customize the CSV returned, by default None
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ MissingRequiredFieldError
+ If the custom view item is missing an ID
+ """
+ if not custom_view_item.id:
+ error = "Custom View item missing ID."
+ raise MissingRequiredFieldError(error)
+
+ def csv_fetcher():
+ return self._get_custom_view_csv(custom_view_item, req_options)
+
+ custom_view_item._set_csv(csv_fetcher)
+ logger.info(f"Populated csv for custom view (ID: {custom_view_item.id})")
+
+ def _get_custom_view_csv(
+ self, custom_view_item: CustomViewItem, req_options: Optional["CSVRequestOptions"]
+ ) -> Iterator[bytes]:
+ url = f"{self.baseurl}/{custom_view_item.id}/data"
+
+ with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response:
+ yield from server_response.iter_content(1024)
+
+ @api(version="3.18")
+ def update(self, view_item: CustomViewItem) -> Optional[CustomViewItem]:
+ """
+ Updates the name, owner, or shared status of a custom view.
+
+ Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#update_custom_view
+
+ Parameters
+ ----------
+ view_item : CustomViewItem
+ The custom view item to update.
+
+ Returns
+ -------
+ Optional[CustomViewItem]
+ The updated custom view item.
+ """
+ if not view_item.id:
+ error = "Custom view item missing ID."
+ raise MissingRequiredFieldError(error)
+ if not (view_item.owner or view_item.name or view_item.shared):
+ logger.debug("No changes to make")
+ return view_item
+
+ # Update the custom view owner or name
+ url = f"{self.baseurl}/{view_item.id}"
+ update_req = RequestFactory.CustomView.update_req(view_item)
+ server_response = self.put_request(url, update_req)
+ logger.info(f"Updated custom view (ID: {view_item.id})")
+ return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace)
+
+ # Delete 1 view by id
+ @api(version="3.19")
+ def delete(self, view_id: str) -> None:
+ """
+ Deletes a single custom view by ID.
+
+ Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#delete_custom_view
+
+ Parameters
+ ----------
+ view_id : str
+ The ID of the custom view to delete.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ ValueError
+ If the view_id is not provided.
+ """
+ if not view_id:
+ error = "Custom View ID undefined."
+ raise ValueError(error)
+ url = f"{self.baseurl}/{view_id}"
+ self.delete_request(url)
+ logger.info(f"Deleted single custom view (ID: {view_id})")
+
+ @api(version="3.21")
+ def download(self, view_item: CustomViewItem, file: PathOrFileW) -> PathOrFileW:
+ """
+ Download the definition of a custom view as json. The file parameter can
+ be a file path or a file object. If a file path is provided, the file
+ will be written to that location. If a file object is provided, the file
+ will be written to that object.
+
+ May contain sensitive information.
+
+ Parameters
+ ----------
+ view_item : CustomViewItem
+ The custom view item to download.
+
+ file : PathOrFileW
+ The file path or file object to write the custom view to.
+
+ Returns
+ -------
+ PathOrFileW
+ The file path or file object that the custom view was written to.
+ """
+ url = f"{self.expurl}/{view_item.id}/content"
+ server_response = self.get_request(url)
+ if isinstance(file, io_types_w):
+ file.write(server_response.content)
+ return file
+
+ with open(file, "wb") as f:
+ f.write(server_response.content)
+
+ return file
+
+ @api(version="3.21")
+ def publish(self, view_item: CustomViewItem, file: PathOrFileR) -> Optional[CustomViewItem]:
+ """
+ Publish a custom view to Tableau Server. The file parameter can be a
+ file path or a file object. If a file path is provided, the file will be
+ read from that location. If a file object is provided, the file will be
+ read from that object.
+
+ Parameters
+ ----------
+ view_item : CustomViewItem
+ The custom view item to publish.
+
+ file : PathOrFileR
+ The file path or file object to read the custom view from.
+
+ Returns
+ -------
+ Optional[CustomViewItem]
+ The published custom view item.
+ """
+ url = self.expurl
+ if isinstance(file, io_types_r):
+ size = get_file_object_size(file)
+ elif isinstance(file, (str, Path)) and (p := Path(file)).is_file():
+ size = p.stat().st_size
+ else:
+ raise ValueError("File path or file object required for publishing custom view.")
+
+ if size >= config.FILESIZE_LIMIT_MB * BYTES_PER_MB:
+ upload_session_id = self.parent_srv.fileuploads.upload(file)
+ url = f"{url}?uploadSessionId={upload_session_id}"
+ xml_request, content_type = RequestFactory.CustomView.publish_req_chunked(view_item)
+ else:
+ if isinstance(file, io_types_r):
+ file.seek(0)
+ contents = file.read()
+ if view_item.name is None:
+ raise MissingRequiredFieldError("Custom view item missing name.")
+ filename = view_item.name
+ elif isinstance(file, (str, Path)):
+ filename = Path(file).name
+ contents = Path(file).read_bytes()
+
+ xml_request, content_type = RequestFactory.CustomView.publish_req(view_item, filename, contents)
+
+ server_response = self.post_request(url, xml_request, content_type)
+ return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace)
+
+ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> "QuerySet[CustomViewItem]":
+ """
+ Queries the Tableau Server for items using the specified filters. Page
+ size can be specified to limit the number of items returned in a single
+ request. If not specified, the default page size is 100. Page size can
+ be an integer between 1 and 1000.
+
+ No positional arguments are allowed. All filters must be specified as
+ keyword arguments. If you use the equality operator, you can specify it
+ through =. If you want to use a different operator,
+ you can specify it through __=. Field
+ names can either be in snake_case or camelCase.
+
+ This endpoint supports the following fields and operators:
+
+ view_id=...
+ workbook_id=...
+ owner_id=...
+ """
+
+ return super().filter(*invalid, page_size=page_size, **kwargs)
diff --git a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py
index c4c80034e..579001156 100644
--- a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py
+++ b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py
@@ -1,24 +1,23 @@
+import logging
+
+from .default_permissions_endpoint import _DefaultPermissionsEndpoint
from .endpoint import api, Endpoint
from .permissions_endpoint import _PermissionsEndpoint
-from .default_permissions_endpoint import _DefaultPermissionsEndpoint
-
-from ...models.data_acceleration_report_item import DataAccelerationReportItem
-
-import logging
+from tableauserverclient.models import DataAccelerationReportItem
-logger = logging.getLogger("tableau.endpoint.data_acceleration_report")
+from tableauserverclient.helpers.logging import logger
class DataAccelerationReport(Endpoint):
def __init__(self, parent_srv):
- super(DataAccelerationReport, self).__init__(parent_srv)
+ super().__init__(parent_srv)
self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl)
self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl)
@property
def baseurl(self):
- return "{0}/sites/{1}/dataAccelerationReport".format(self.parent_srv.baseurl, self.parent_srv.site_id)
+ return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/dataAccelerationReport"
@api(version="3.8")
def get(self, req_options=None):
diff --git a/tableauserverclient/server/endpoint/data_alert_endpoint.py b/tableauserverclient/server/endpoint/data_alert_endpoint.py
index d2e5d55a7..ba3ecd74f 100644
--- a/tableauserverclient/server/endpoint/data_alert_endpoint.py
+++ b/tableauserverclient/server/endpoint/data_alert_endpoint.py
@@ -1,25 +1,30 @@
+import logging
+
from .endpoint import api, Endpoint
from .exceptions import MissingRequiredFieldError
-from .permissions_endpoint import _PermissionsEndpoint
-from .default_permissions_endpoint import _DefaultPermissionsEndpoint
+from tableauserverclient.server import RequestFactory
+from tableauserverclient.models import DataAlertItem, PaginationItem, UserItem
-from .. import RequestFactory, DataAlertItem, PaginationItem, UserItem
+from tableauserverclient.helpers.logging import logger
+
+from typing import Optional, TYPE_CHECKING, Union
-import logging
-logger = logging.getLogger("tableau.endpoint.dataAlerts")
+if TYPE_CHECKING:
+ from ..server import Server
+ from ..request_options import RequestOptions
class DataAlerts(Endpoint):
- def __init__(self, parent_srv):
- super(DataAlerts, self).__init__(parent_srv)
+ def __init__(self, parent_srv: "Server") -> None:
+ super().__init__(parent_srv)
@property
- def baseurl(self):
- return "{0}/sites/{1}/dataAlerts".format(self.parent_srv.baseurl, self.parent_srv.site_id)
+ def baseurl(self) -> str:
+ return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/dataAlerts"
@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,30 +34,45 @@ 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)
- logger.info("Querying single dataAlert (ID: {0})".format(dataAlert_id))
- url = "{0}/{1}".format(self.baseurl, dataAlert_id)
+ logger.info(f"Querying single dataAlert (ID: {dataAlert_id})")
+ url = f"{self.baseurl}/{dataAlert_id}"
server_response = self.get_request(url)
return DataAlertItem.from_response(server_response.content, self.parent_srv.namespace)[0]
@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)
# DELETE /api/api-version/sites/site-id/dataAlerts/data-alert-id/users/user-id
- url = "{0}/{1}".format(self.baseurl, dataAlert_id)
+ url = f"{self.baseurl}/{dataAlert_id}"
self.delete_request(url)
- logger.info("Deleted single dataAlert (ID: {0})".format(dataAlert_id))
+ logger.info(f"Deleted single dataAlert (ID: {dataAlert_id})")
@api(version="3.2")
- def delete_user_from_alert(self, dataAlert, 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)
@@ -60,35 +80,40 @@ def delete_user_from_alert(self, dataAlert, user):
error = "User ID undefined."
raise ValueError(error)
# DELETE /api/api-version/sites/site-id/dataAlerts/data-alert-id/users/user-id
- url = "{0}/{1}/users/{2}".format(self.baseurl, dataAlert_id, user_id)
+ url = f"{self.baseurl}/{dataAlert_id}/users/{user_id}"
self.delete_request(url)
- logger.info("Deleted User (ID {0}) from dataAlert (ID: {1})".format(user_id, dataAlert_id))
+ logger.info(f"Deleted User (ID {user_id}) from dataAlert (ID: {dataAlert_id})")
@api(version="3.2")
- def add_user_to_alert(self, dataAlert_item, 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)
- url = "{0}/{1}/users".format(self.baseurl, dataAlert_item.id)
+ url = f"{self.baseurl}/{dataAlert_item.id}/users"
update_req = RequestFactory.DataAlert.add_user_to_alert(dataAlert_item, user_id)
server_response = self.post_request(url, update_req)
- logger.info("Added user (ID {0}) to dataAlert item (ID: {1})".format(user_id, dataAlert_item.id))
- user = UserItem.from_response(server_response.content, self.parent_srv.namespace)[0]
- return user
+ logger.info(f"Added user (ID {user_id}) to dataAlert item (ID: {dataAlert_item.id})")
+ added_user = UserItem.from_response(server_response.content, self.parent_srv.namespace)[0]
+ return added_user
@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)
- url = "{0}/{1}".format(self.baseurl, dataAlert_item.id)
+ url = f"{self.baseurl}/{dataAlert_item.id}"
update_req = RequestFactory.DataAlert.update_req(dataAlert_item)
server_response = self.put_request(url, update_req)
- logger.info("Updated dataAlert item (ID: {0})".format(dataAlert_item.id))
+ logger.info(f"Updated dataAlert item (ID: {dataAlert_item.id})")
updated_dataAlert = DataAlertItem.from_response(server_response.content, self.parent_srv.namespace)[0]
return updated_dataAlert
diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py
index 50826ee0b..dc88ceaa5 100644
--- a/tableauserverclient/server/endpoint/databases_endpoint.py
+++ b/tableauserverclient/server/endpoint/databases_endpoint.py
@@ -1,30 +1,56 @@
-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
+import logging
+from typing import TYPE_CHECKING, Optional, Union
+from collections.abc import Iterable
-from .. import RequestFactory, DatabaseItem, TableItem, PaginationItem, Permission
+from tableauserverclient.models.permissions_item import PermissionsRule
+from tableauserverclient.server.endpoint.default_permissions_endpoint import _DefaultPermissionsEndpoint
+from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint
+from tableauserverclient.server.endpoint.endpoint import api, Endpoint
+from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError
+from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint
+from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin
+from tableauserverclient.server import RequestFactory
+from tableauserverclient.models import DatabaseItem, TableItem, PaginationItem, Resource
-import logging
+from tableauserverclient.helpers.logging import logger
-logger = logging.getLogger("tableau.endpoint.databases")
+if TYPE_CHECKING:
+ from tableauserverclient.models.dqw_item import DQWItem
+ from tableauserverclient.server.request_options import RequestOptions
-class Databases(Endpoint):
+class Databases(Endpoint, TaggingMixin):
def __init__(self, parent_srv):
- super(Databases, self).__init__(parent_srv)
+ super().__init__(parent_srv)
self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl)
self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl)
- self._data_quality_warnings = _DataQualityWarningEndpoint(parent_srv, "database")
+ self._data_quality_warnings = _DataQualityWarningEndpoint(parent_srv, Resource.Database)
@property
- def baseurl(self):
- return "{0}/sites/{1}/databases".format(self.parent_srv.baseurl, self.parent_srv.site_id)
+ def baseurl(self) -> str:
+ return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/databases"
@api(version="3.5")
- def get(self, req_options=None):
+ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[DatabaseItem], PaginationItem]:
+ """
+ Get information about all databases on the site. Endpoint is paginated,
+ and will return a default of 100 items per page. Use the `req_options`
+ parameter to customize the request.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_databases
+
+ Parameters
+ ----------
+ req_options : RequestOptions, optional
+ Options to customize the request. If not provided, defaults to None.
+
+ Returns
+ -------
+ tuple[list[DatabaseItem], PaginationItem]
+ A tuple containing a list of DatabaseItem objects and a
+ PaginationItem object.
+ """
logger.info("Querying all databases on site")
url = self.baseurl
server_response = self.get_request(url, req_options)
@@ -34,34 +60,92 @@ def get(self, req_options=None):
# Get 1 database
@api(version="3.5")
- def get_by_id(self, database_id):
+ def get_by_id(self, database_id: str) -> DatabaseItem:
+ """
+ Get information about a single database asset on the site.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_database
+
+ Parameters
+ ----------
+ database_id : str
+ The ID of the database to retrieve.
+
+ Returns
+ -------
+ DatabaseItem
+ A DatabaseItem object representing the database.
+
+ Raises
+ ------
+ ValueError
+ If the database ID is undefined.
+ """
if not database_id:
error = "database ID undefined."
raise ValueError(error)
- logger.info("Querying single database (ID: {0})".format(database_id))
- url = "{0}/{1}".format(self.baseurl, database_id)
+ logger.info(f"Querying single database (ID: {database_id})")
+ url = f"{self.baseurl}/{database_id}"
server_response = self.get_request(url)
return DatabaseItem.from_response(server_response.content, self.parent_srv.namespace)[0]
@api(version="3.5")
- def delete(self, database_id):
+ def delete(self, database_id: str) -> None:
+ """
+ Deletes a single database asset from the server.
+
+ Parameters
+ ----------
+ database_id : str
+ The ID of the database to delete.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ ValueError
+ If the database ID is undefined.
+ """
if not database_id:
error = "Database ID undefined."
raise ValueError(error)
- url = "{0}/{1}".format(self.baseurl, database_id)
+ url = f"{self.baseurl}/{database_id}"
self.delete_request(url)
- logger.info("Deleted single database (ID: {0})".format(database_id))
+ logger.info(f"Deleted single database (ID: {database_id})")
@api(version="3.5")
- def update(self, database_item):
+ def update(self, database_item: DatabaseItem) -> DatabaseItem:
+ """
+ Update the database description, certify the database, set permissions,
+ or assign a User as the database contact.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#update_database
+
+ Parameters
+ ----------
+ database_item : DatabaseItem
+ The DatabaseItem object to update.
+
+ Returns
+ -------
+ DatabaseItem
+ The updated DatabaseItem object.
+
+ Raises
+ ------
+ MissingRequiredFieldError
+ If the database item is missing an ID.
+ """
if not database_item.id:
error = "Database item missing ID."
raise MissingRequiredFieldError(error)
- url = "{0}/{1}".format(self.baseurl, database_item.id)
+ url = f"{self.baseurl}/{database_item.id}"
update_req = RequestFactory.Database.update_req(database_item)
server_response = self.put_request(url, update_req)
- logger.info("Updated database item (ID: {0})".format(database_item.id))
+ logger.info(f"Updated database item (ID: {database_item.id})")
updated_database = DatabaseItem.from_response(server_response.content, self.parent_srv.namespace)[0]
return updated_database
@@ -76,61 +160,64 @@ def column_fetcher():
return self._get_tables_for_database(database_item)
database_item._set_tables(column_fetcher)
- logger.info("Populated tables for database (ID: {0}".format(database_item.id))
+ logger.info(f"Populated tables for database (ID: {database_item.id}")
def _get_tables_for_database(self, database_item):
- url = "{0}/{1}/tables".format(self.baseurl, database_item.id)
+ url = f"{self.baseurl}/{database_item.id}/tables"
server_response = self.get_request(url)
tables = TableItem.from_response(server_response.content, self.parent_srv.namespace)
return tables
@api(version="3.5")
- def populate_permissions(self, item):
+ def populate_permissions(self, item: DatabaseItem) -> None:
self._permissions.populate(item)
@api(version="3.5")
- def update_permission(self, item, rules):
- import warnings
-
- warnings.warn(
- "Server.databases.update_permission is deprecated, "
- "please use Server.databases.update_permissions instead.",
- DeprecationWarning,
- )
- return self._permissions.update(item, rules)
-
- @api(version="3.5")
- def update_permissions(self, item, rules):
+ def update_permissions(self, item: DatabaseItem, rules: list[PermissionsRule]) -> list[PermissionsRule]:
return self._permissions.update(item, rules)
@api(version="3.5")
- def delete_permission(self, item, rules):
+ def delete_permission(self, item: DatabaseItem, rules: list[PermissionsRule]) -> None:
self._permissions.delete(item, rules)
@api(version="3.5")
- def populate_table_default_permissions(self, item):
- self._default_permissions.populate_default_permissions(item, Permission.Resource.Table)
+ def populate_table_default_permissions(self, item: DatabaseItem):
+ self._default_permissions.populate_default_permissions(item, Resource.Table)
@api(version="3.5")
- def update_table_default_permissions(self, item):
- return self._default_permissions.update_default_permissions(item, Permission.Resource.Table)
+ def update_table_default_permissions(
+ self, item: DatabaseItem, rules: list[PermissionsRule]
+ ) -> list[PermissionsRule]:
+ return self._default_permissions.update_default_permissions(item, rules, Resource.Table)
@api(version="3.5")
- def delete_table_default_permissions(self, item):
- self._default_permissions.delete_default_permissions(item, Permission.Resource.Table)
+ def delete_table_default_permissions(self, rule: PermissionsRule, item: DatabaseItem) -> None:
+ self._default_permissions.delete_default_permission(item, rule, Resource.Table)
@api(version="3.5")
- def populate_dqw(self, item):
+ def populate_dqw(self, item: DatabaseItem) -> None:
self._data_quality_warnings.populate(item)
@api(version="3.5")
- def update_dqw(self, item, warning):
+ def update_dqw(self, item: DatabaseItem, warning: "DQWItem") -> list["DQWItem"]:
return self._data_quality_warnings.update(item, warning)
@api(version="3.5")
- def add_dqw(self, item, warning):
+ def add_dqw(self, item: DatabaseItem, warning: "DQWItem") -> list["DQWItem"]:
return self._data_quality_warnings.add(item, warning)
@api(version="3.5")
- def delete_dqw(self, item):
+ def delete_dqw(self, item: DatabaseItem) -> None:
self._data_quality_warnings.clear(item)
+
+ @api(version="3.9")
+ def add_tags(self, item: Union[DatabaseItem, str], tags: Iterable[str]) -> set[str]:
+ return super().add_tags(item, tags)
+
+ @api(version="3.9")
+ def delete_tags(self, item: Union[DatabaseItem, str], tags: Iterable[str]) -> None:
+ super().delete_tags(item, tags)
+
+ @api(version="3.9")
+ def update_tags(self, item: DatabaseItem) -> None:
+ raise NotImplementedError("Update tags is not supported for databases.")
diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py
index c031004e0..168446974 100644
--- a/tableauserverclient/server/endpoint/datasources_endpoint.py
+++ b/tableauserverclient/server/endpoint/datasources_endpoint.py
@@ -1,47 +1,99 @@
-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 ..query import QuerySet
-from ...filesys_helpers import (
- to_filename,
+from email.message import Message
+import copy
+import json
+import io
+import os
+
+from contextlib import closing
+from pathlib import Path
+from typing import Literal, Optional, TYPE_CHECKING, Union, overload
+from collections.abc import Iterable, Mapping, Sequence
+
+from tableauserverclient.helpers.headers import fix_filename
+from tableauserverclient.models.dqw_item import DQWItem
+from tableauserverclient.server.query import QuerySet
+
+if TYPE_CHECKING:
+ from tableauserverclient.server import Server
+ from tableauserverclient.models import PermissionsRule
+ from .schedules_endpoint import AddResponse
+
+from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint
+from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api, parameter_added_in
+from tableauserverclient.server.endpoint.exceptions import InternalServerError, MissingRequiredFieldError
+from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint
+from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin
+
+from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, BYTES_PER_MB, config
+from tableauserverclient.filesys_helpers import (
make_download_path,
get_file_type,
get_file_object_size,
+ to_filename,
)
-from ...models.job_item import JobItem
-
-import os
-import logging
-import copy
-import cgi
-from contextlib import closing
-import json
+from tableauserverclient.helpers.logging import logger
+from tableauserverclient.models import (
+ ConnectionCredentials,
+ ConnectionItem,
+ DatasourceItem,
+ JobItem,
+ RevisionItem,
+ PaginationItem,
+)
+from tableauserverclient.server import RequestFactory, RequestOptions
-# The maximum size of a file that can be published in a single request is 64MB
-FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB
+io_types = (io.BytesIO, io.BufferedReader)
+io_types_r = (io.BytesIO, io.BufferedReader)
+io_types_w = (io.BytesIO, io.BufferedWriter)
-ALLOWED_FILE_EXTENSIONS = ["tds", "tdsx", "tde", "hyper"]
+FilePath = Union[str, os.PathLike]
+FileObject = Union[io.BufferedReader, io.BytesIO]
+PathOrFile = Union[FilePath, FileObject]
-logger = logging.getLogger("tableau.endpoint.datasources")
+FilePath = Union[str, os.PathLike]
+FileObjectR = Union[io.BufferedReader, io.BytesIO]
+FileObjectW = Union[io.BufferedWriter, io.BytesIO]
+PathOrFileR = Union[FilePath, FileObjectR]
+PathOrFileW = Union[FilePath, FileObjectW]
-class Datasources(QuerysetEndpoint):
- def __init__(self, parent_srv):
- super(Datasources, self).__init__(parent_srv)
- self._resource_tagger = _ResourceTagger(parent_srv)
+class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]):
+ def __init__(self, parent_srv: "Server") -> None:
+ super().__init__(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):
- return "{0}/sites/{1}/datasources".format(self.parent_srv.baseurl, self.parent_srv.site_id)
+ def baseurl(self) -> str:
+ return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/datasources"
# Get all datasources
@api(version="2.0")
- def get(self, req_options=None):
+ def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[DatasourceItem], PaginationItem]:
+ """
+ Returns a list of published data sources on the specified site, with
+ optional parameters for specifying the paging of large results. To get
+ a list of data sources embedded in a workbook, use the Query Workbook
+ Connections method.
+
+ Endpoint is paginated, and will return one page per call.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#query_data_sources
+
+ Parameters
+ ----------
+ req_options : Optional[RequestOptions]
+ Optional parameters for the request, such as filters, sorting, page
+ size, and page number.
+
+ Returns
+ -------
+ tuple[list[DatasourceItem], PaginationItem]
+ A tuple containing the list of datasource items and pagination
+ information.
+ """
logger.info("Querying all datasources on site")
url = self.baseurl
server_response = self.get_request(url, req_options)
@@ -51,18 +103,47 @@ 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:
+ """
+ Returns information about a specific data source.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#query_data_source
+
+ Parameters
+ ----------
+ datasource_id : str
+ The unique ID of the datasource to retrieve.
+
+ Returns
+ -------
+ DatasourceItem
+ An object containing information about the datasource.
+ """
if not datasource_id:
error = "Datasource ID undefined."
raise ValueError(error)
- logger.info("Querying single datasource (ID: {0})".format(datasource_id))
- url = "{0}/{1}".format(self.baseurl, datasource_id)
+ logger.info(f"Querying single datasource (ID: {datasource_id})")
+ url = f"{self.baseurl}/{datasource_id}"
server_response = self.get_request(url)
return DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0]
# Populate datasource item's connections
@api(version="2.0")
- def populate_connections(self, datasource_item):
+ def populate_connections(self, datasource_item: DatasourceItem) -> None:
+ """
+ Retrieve connection information for the specificed datasource item.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#query_data_source_connections
+
+ Parameters
+ ----------
+ datasource_item : DatasourceItem
+ The datasource item to retrieve connections for.
+
+ Returns
+ -------
+ None
+ """
if not datasource_item.id:
error = "Datasource item missing ID. Datasource must be retrieved from server first."
raise MissingRequiredFieldError(error)
@@ -71,139 +152,338 @@ def connections_fetcher():
return self._get_datasource_connections(datasource_item)
datasource_item._set_connections(connections_fetcher)
- logger.info("Populated connections for datasource (ID: {0})".format(datasource_item.id))
+ logger.info(f"Populated connections for datasource (ID: {datasource_item.id})")
- def _get_datasource_connections(self, datasource_item, req_options=None):
- url = "{0}/{1}/connections".format(self.baseurl, datasource_item.id)
+ def _get_datasource_connections(
+ self, datasource_item: DatasourceItem, req_options: Optional[RequestOptions] = None
+ ) -> list[ConnectionItem]:
+ url = f"{self.baseurl}/{datasource_item.id}/connections"
server_response = self.get_request(url, req_options)
connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)
+ for connection in connections:
+ connection._datasource_id = datasource_item.id
+ connection._datasource_name = datasource_item.name
return connections
# Delete 1 datasource by id
@api(version="2.0")
- def delete(self, datasource_id):
+ def delete(self, datasource_id: str) -> None:
+ """
+ Deletes the specified data source from a site. When a data source is
+ deleted, its associated data connection is also deleted. Workbooks that
+ use the data source are not deleted, but they will no longer work
+ properly.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#delete_data_source
+
+ Parameters
+ ----------
+ datasource_id : str
+
+ Returns
+ -------
+ None
+ """
if not datasource_id:
error = "Datasource ID undefined."
raise ValueError(error)
- url = "{0}/{1}".format(self.baseurl, datasource_id)
+ url = f"{self.baseurl}/{datasource_id}"
self.delete_request(url)
- logger.info("Deleted single datasource (ID: {0})".format(datasource_id))
+ logger.info(f"Deleted single datasource (ID: {datasource_id})")
# Download 1 datasource by id
@api(version="2.0")
@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):
- if not datasource_id:
- error = "Datasource ID undefined."
- raise ValueError(error)
- url = "{0}/{1}/content".format(self.baseurl, datasource_id)
+ def download(
+ self,
+ datasource_id: str,
+ filepath: Optional[PathOrFileW] = None,
+ include_extract: bool = True,
+ ) -> PathOrFileW:
+ """
+ Downloads the specified data source from a site. The data source is
+ downloaded as a .tdsx file.
- if no_extract is False or no_extract is True:
- import warnings
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#download_data_source
- warnings.warn(
- "no_extract is deprecated, use include_extract instead.",
- DeprecationWarning,
- )
- include_extract = not no_extract
+ Parameters
+ ----------
+ datasource_id : str
+ The unique ID of the datasource to download.
- if not include_extract:
- url += "?includeExtract=False"
+ filepath : Optional[PathOrFileW]
+ The file path to save the downloaded datasource to. If not
+ specified, the file will be saved to the current working directory.
- 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"]))
+ include_extract : bool, default True
+ If True, the extract is included in the download. If False, the
+ extract is not included.
- download_path = make_download_path(filepath, filename)
-
- with open(download_path, "wb") as f:
- for chunk in server_response.iter_content(1024): # 1KB
- f.write(chunk)
-
- logger.info("Downloaded datasource to {0} (ID: {1})".format(download_path, datasource_id))
- return os.path.abspath(download_path)
+ Returns
+ -------
+ filepath : PathOrFileW
+ """
+ return self.download_revision(
+ datasource_id,
+ None,
+ filepath,
+ include_extract,
+ )
# Update datasource
@api(version="2.0")
- def update(self, datasource_item):
+ def update(self, datasource_item: DatasourceItem) -> DatasourceItem:
+ """
+ Updates the owner, project or certification status of the specified
+ data source.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#update_data_source
+
+ Parameters
+ ----------
+ datasource_item : DatasourceItem
+ The datasource item to update.
+
+ Returns
+ -------
+ DatasourceItem
+ An object containing information about the updated datasource.
+
+ Raises
+ ------
+ MissingRequiredFieldError
+ If the datasource item is missing an ID.
+ """
+
if not datasource_item.id:
error = "Datasource item missing ID. Datasource must be retrieved from server first."
raise MissingRequiredFieldError(error)
+ # bug - before v3.15 you must always include the project id
+ if datasource_item.owner_id and not datasource_item.project_id:
+ if not self.parent_srv.check_at_least_version("3.15"):
+ error = (
+ "Attempting to set new owner but datasource is missing Project ID."
+ "In versions before 3.15 the project id must be included to update the owner."
+ )
+ raise MissingRequiredFieldError(error)
- self._resource_tagger.update_tags(self.baseurl, datasource_item)
+ self.update_tags(datasource_item)
# Update the datasource itself
- url = "{0}/{1}".format(self.baseurl, datasource_item.id)
+ url = f"{self.baseurl}/{datasource_item.id}"
+
update_req = RequestFactory.Datasource.update_req(datasource_item)
server_response = self.put_request(url, update_req)
- logger.info("Updated datasource item (ID: {0})".format(datasource_item.id))
+ logger.info(f"Updated datasource item (ID: {datasource_item.id})")
updated_datasource = copy.copy(datasource_item)
return updated_datasource._parse_common_elements(server_response.content, self.parent_srv.namespace)
# Update datasource connections
@api(version="2.3")
- def update_connection(self, datasource_item, connection_item):
- url = "{0}/{1}/connections/{2}".format(self.baseurl, datasource_item.id, connection_item.id)
+ def update_connection(
+ self, datasource_item: DatasourceItem, connection_item: ConnectionItem
+ ) -> Optional[ConnectionItem]:
+ """
+ Updates the server address, port, username, or password for the
+ specified data source connection.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#update_data_source_connection
+
+ Parameters
+ ----------
+ datasource_item : DatasourceItem
+ The datasource item to update.
+
+ connection_item : ConnectionItem
+ The connection item to update.
+
+ Returns
+ -------
+ Optional[ConnectionItem]
+ An object containing information about the updated connection.
+ """
+
+ url = f"{self.baseurl}/{datasource_item.id}/connections/{connection_item.id}"
update_req = RequestFactory.Connection.update_req(connection_item)
server_response = self.put_request(url, update_req)
- connection = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0]
+ connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)
+ if not connections:
+ return None
- logger.info(
- "Updated datasource item (ID: {0} & connection item {1}".format(datasource_item.id, connection_item.id)
- )
+ if len(connections) > 1:
+ logger.debug(f"Multiple connections returned ({len(connections)})")
+ connection = list(filter(lambda x: x.id == connection_item.id, connections))[0]
+
+ logger.info(f"Updated datasource item (ID: {datasource_item.id} & connection item {connection_item.id}")
return connection
@api(version="2.8")
- def refresh(self, datasource_item):
+ def refresh(self, datasource_item: DatasourceItem, incremental: bool = False) -> JobItem:
+ """
+ Refreshes the extract of an existing workbook.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#run_extract_refresh_task
+
+ Parameters
+ ----------
+ workbook_item : WorkbookItem | str
+ The workbook item or workbook ID.
+ incremental: bool
+ Whether to do a full refresh or incremental refresh of the extract data
+
+ Returns
+ -------
+ JobItem
+ The job item.
+ """
id_ = getattr(datasource_item, "id", datasource_item)
- url = "{0}/{1}/refresh".format(self.baseurl, id_)
- empty_req = RequestFactory.Empty.empty_req()
- server_response = self.post_request(url, empty_req)
+ url = f"{self.baseurl}/{id_}/refresh"
+ refresh_req = RequestFactory.Task.refresh_req(incremental, self.parent_srv)
+ server_response = self.post_request(url, refresh_req)
new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0]
return new_job
@api(version="3.5")
- def create_extract(self, datasource_item, encrypt=False):
+ def create_extract(self, datasource_item: DatasourceItem, encrypt: bool = False) -> JobItem:
+ """
+ Create an extract for a data source in a site. Optionally, encrypt the
+ extract if the site and workbooks using it are configured to allow it.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#create_extract_for_datasource
+
+ Parameters
+ ----------
+ datasource_item : DatasourceItem | str
+ The datasource item or datasource ID.
+
+ encrypt : bool, default False
+ Whether to encrypt the extract.
+
+ Returns
+ -------
+ JobItem
+ The job item.
+ """
id_ = getattr(datasource_item, "id", datasource_item)
- url = "{0}/{1}/createExtract?encrypt={2}".format(self.baseurl, id_, encrypt)
+ url = f"{self.baseurl}/{id_}/createExtract?encrypt={encrypt}"
empty_req = RequestFactory.Empty.empty_req()
server_response = self.post_request(url, empty_req)
new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0]
return new_job
@api(version="3.5")
- def delete_extract(self, datasource_item):
+ def delete_extract(self, datasource_item: DatasourceItem) -> None:
+ """
+ Delete the extract of a data source in a site.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#delete_extract_from_datasource
+
+ Parameters
+ ----------
+ datasource_item : DatasourceItem | str
+ The datasource item or datasource ID.
+
+ Returns
+ -------
+ None
+ """
id_ = getattr(datasource_item, "id", datasource_item)
- url = "{0}/{1}/deleteExtract".format(self.baseurl, id_)
+ url = f"{self.baseurl}/{id_}/deleteExtract"
empty_req = RequestFactory.Empty.empty_req()
self.post_request(url, empty_req)
+ @overload
+ def publish(
+ self,
+ datasource_item: DatasourceItem,
+ file: PathOrFileR,
+ mode: str,
+ connection_credentials: Optional[ConnectionCredentials] = None,
+ connections: Optional[Sequence[ConnectionItem]] = None,
+ as_job: Literal[False] = False,
+ ) -> DatasourceItem:
+ pass
+
+ @overload
+ def publish(
+ self,
+ datasource_item: DatasourceItem,
+ file: PathOrFileR,
+ mode: str,
+ connection_credentials: Optional[ConnectionCredentials] = None,
+ connections: Optional[Sequence[ConnectionItem]] = None,
+ as_job: Literal[True] = True,
+ ) -> JobItem:
+ pass
+
# Publish datasource
@api(version="2.0")
@parameter_added_in(connections="2.8")
@parameter_added_in(as_job="3.0")
def publish(
self,
- datasource_item,
- file,
- mode,
- connection_credentials=None,
- connections=None,
- as_job=False,
- ):
+ datasource_item: DatasourceItem,
+ file: PathOrFileR,
+ mode: str,
+ connection_credentials: Optional[ConnectionCredentials] = None,
+ connections: Optional[Sequence[ConnectionItem]] = None,
+ as_job: bool = False,
+ ) -> Union[DatasourceItem, JobItem]:
+ """
+ Publishes a data source to a server, or appends data to an existing
+ data source.
- try:
+ This method checks the size of the data source and automatically
+ determines whether the publish the data source in multiple parts or in
+ one operation.
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#publish_data_source
+
+ Parameters
+ ----------
+ datasource_item : DatasourceItem
+ The datasource item to publish. The fields for name and project_id
+ are required.
+
+ file : PathOrFileR
+ The file path or file object to publish.
+
+ mode : str
+ Specifies whether you are publishing a new datasource (CreateNew),
+ overwriting an existing datasource (Overwrite), or add to an
+ existing datasource (Append). You can also use the publish mode
+ attributes, for example: TSC.Server.PublishMode.Overwrite.
+
+ connection_credentials : Optional[ConnectionCredentials]
+ The connection credentials to use when publishing the datasource.
+ Mutually exclusive with the connections parameter.
+
+ connections : Optional[Sequence[ConnectionItem]]
+ The connections to use when publishing the datasource. Mutually
+ exclusive with the connection_credentials parameter.
+
+ as_job : bool, default False
+ If True, the publish operation is asynchronous and returns a job
+ item. If False, the publish operation is synchronous and returns a
+ datasource item.
+
+ Returns
+ -------
+ Union[DatasourceItem, JobItem]
+ The datasource item or job item.
+
+ """
+ if isinstance(file, (os.PathLike, str)):
if not os.path.isfile(file):
error = "File path does not lead to an existing file."
- raise IOError(error)
+ raise OSError(error)
filename = os.path.basename(file)
file_extension = os.path.splitext(filename)[1][1:]
file_size = os.path.getsize(file)
-
+ logger.debug(f"Publishing file `{filename}`, size `{file_size}`")
# If name is not defined, grab the name from the file to publish
if not datasource_item.name:
datasource_item.name = os.path.splitext(filename)[0]
@@ -211,8 +491,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_r):
if not datasource_item.name:
error = "Datasource item must have a name when passing a file object"
raise ValueError(error)
@@ -223,40 +502,48 @@ def publish(
elif file_type == "xml":
file_extension = "tds"
else:
- error = "Unsupported file type {}".format(file_type)
+ error = f"Unsupported file type {file_type}"
raise ValueError(error)
- filename = "{}.{}".format(datasource_item.name, file_extension)
+ filename = f"{datasource_item.name}.{file_extension}"
file_size = get_file_object_size(file)
- if not mode or not hasattr(self.parent_srv.PublishMode, mode):
- error = "Invalid mode defined."
- raise ValueError(error)
+ else:
+ raise TypeError("file should be a filepath or file object.")
# Construct the url with the defined mode
- url = "{0}?datasourceType={1}".format(self.baseurl, file_extension)
- if mode == self.parent_srv.PublishMode.Overwrite or mode == self.parent_srv.PublishMode.Append:
- url += "&{0}=true".format(mode.lower())
+ url = f"{self.baseurl}?datasourceType={file_extension}"
+ if not mode or not hasattr(self.parent_srv.PublishMode, mode):
+ error = f"Invalid mode defined: {mode}"
+ raise ValueError(error)
+ else:
+ url += f"&{mode.lower()}=true"
if as_job:
- url += "&{0}=true".format("asJob")
+ url += "&{}=true".format("asJob")
# Determine if chunking is required (64MB is the limit for single upload method)
- if file_size >= FILESIZE_LIMIT:
- logger.info("Publishing {0} to server with chunking method (datasource over 64MB)".format(filename))
+ if file_size >= config.FILESIZE_LIMIT_MB * BYTES_PER_MB:
+ logger.info(
+ "Publishing {} to server with chunking method (datasource over {}MB, chunk size {}MB)".format(
+ filename, config.FILESIZE_LIMIT_MB, config.CHUNK_SIZE_MB
+ )
+ )
upload_session_id = self.parent_srv.fileuploads.upload(file)
- url = "{0}&uploadSessionId={1}".format(url, upload_session_id)
+ url = f"{url}&uploadSessionId={upload_session_id}"
xml_request, content_type = RequestFactory.Datasource.publish_req_chunked(
datasource_item, connection_credentials, connections
)
else:
- logger.info("Publishing {0} to server".format(filename))
+ logger.info(f"Publishing {filename} to server")
- try:
+ if isinstance(file, (Path, str)):
with open(file, "rb") as f:
file_contents = f.read()
- except TypeError:
+ elif isinstance(file, io_types_r):
file_contents = file.read()
+ else:
+ raise TypeError("file should be a filepath or file object.")
xml_request, content_type = RequestFactory.Datasource.publish_req(
datasource_item,
@@ -276,34 +563,86 @@ def publish(
if as_job:
new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0]
- logger.info("Published {0} (JOB_ID: {1}".format(filename, new_job.id))
+ logger.info(f"Published {filename} (JOB_ID: {new_job.id}")
return new_job
else:
new_datasource = DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0]
- logger.info("Published {0} (ID: {1})".format(filename, new_datasource.id))
+ logger.info(f"Published {filename} (ID: {new_datasource.id})")
return new_datasource
@api(version="3.13")
- 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:
+ """
+ Incrementally updates data (insert, update, upsert, replace and delete)
+ in a published data source from a live-to-Hyper connection, where the
+ data source has multiple connections.
+
+ A live-to-Hyper connection has a Hyper or Parquet formatted
+ file/database as the origin of its data.
+
+ For all connections to Parquet files, and for any data sources with a
+ single connection generally, you can use the Update Data in Hyper Data
+ Source method without specifying the connection-id.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#update_data_in_hyper_connection
+
+ Parameters
+ ----------
+ datasource_or_connection_item : Union[DatasourceItem, ConnectionItem, str]
+ The datasource item, connection item, or datasource ID. Either a
+ DataSourceItem or a ConnectionItem. If the datasource only contains
+ a single connection, the DataSourceItem is sufficient to identify
+ which data should be updated. Otherwise, for datasources with
+ multiple connections, a ConnectionItem must be provided.
+
+ request_id : str
+ User supplied arbitrary string to identify the request. A request
+ identified with the same key will only be executed once, even if
+ additional requests using the key are made, for instance, due to
+ retries when facing network issues.
+
+ actions : Sequence[Mapping]
+ A list of actions (insert, update, delete, ...) specifying how to
+ modify the data within the published datasource. For more
+ information on the actions, see: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_how_to_update_data_to_hyper.htm#action-batch-descriptions
+
+ payload : Optional[FilePath]
+ A Hyper file containing tuples to be inserted/deleted/updated or
+ other payload data used by the actions. Hyper files can be created
+ using the Tableau Hyper API or pantab.
+
+ Returns
+ -------
+ JobItem
+ The job running on the server.
+
+ """
if isinstance(datasource_or_connection_item, DatasourceItem):
datasource_id = datasource_or_connection_item.id
- url = "{0}/{1}/data".format(self.baseurl, datasource_id)
+ url = f"{self.baseurl}/{datasource_id}/data"
elif isinstance(datasource_or_connection_item, ConnectionItem):
datasource_id = datasource_or_connection_item.datasource_id
connection_id = datasource_or_connection_item.id
- url = "{0}/{1}/connections/{2}/data".format(self.baseurl, datasource_id, connection_id)
+ url = f"{self.baseurl}/{datasource_id}/connections/{connection_id}/data"
else:
assert isinstance(datasource_or_connection_item, str)
- url = "{0}/{1}/data".format(self.baseurl, datasource_or_connection_item)
+ url = f"{self.baseurl}/{datasource_or_connection_item}/data"
if payload is not None:
if not os.path.isfile(payload):
error = "File path does not lead to an existing file."
- raise IOError(error)
+ raise OSError(error)
- logger.info("Uploading {0} to server with chunking method for Update job".format(payload))
+ logger.info(f"Uploading {payload} to server with chunking method for Update job")
upload_session_id = self.parent_srv.fileuploads.upload(payload)
- url = "{0}?uploadSessionId={1}".format(url, upload_session_id)
+ url = f"{url}?uploadSessionId={upload_session_id}"
json_request = json.dumps({"actions": actions})
parameters = {"headers": {"requestid": request_id}}
@@ -312,40 +651,462 @@ 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:
+ """
+ Populates the permissions on the specified datasource item.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_data_source_permissions
+
+ Parameters
+ ----------
+ item : DatasourceItem
+ The datasource item to populate permissions for.
+
+ Returns
+ -------
+ None
+ """
self._permissions.populate(item)
@api(version="2.0")
- def update_permission(self, item, permission_item):
- import warnings
+ def update_permissions(self, item: DatasourceItem, permission_item: list["PermissionsRule"]) -> None:
+ """
+ Updates the permissions on the specified datasource item. This method
+ overwrites all existing permissions. Any permissions not included in
+ the list will be removed.
- warnings.warn(
- "Server.datasources.update_permission is deprecated, "
- "please use Server.datasources.update_permissions instead.",
- DeprecationWarning,
- )
- self._permissions.update(item, permission_item)
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_permissions_for_content
- @api(version="2.0")
- def update_permissions(self, item, permission_item):
+ Parameters
+ ----------
+ item : DatasourceItem
+ The datasource item to update permissions for.
+
+ permission_item : list[PermissionsRule]
+ The permissions to apply to the datasource item.
+
+ Returns
+ -------
+ None
+ """
self._permissions.update(item, permission_item)
@api(version="2.0")
- def delete_permission(self, item, capability_item):
+ def delete_permission(self, item: DatasourceItem, capability_item: "PermissionsRule") -> None:
+ """
+ Deletes a single permission rule from the specified datasource item.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_data_source_permissionDatasourceItem
+
+ Parameters
+ ----------
+ item : DatasourceItem
+ The datasource item to delete permissions from.
+
+ capability_item : PermissionsRule
+ The permission rule to delete.
+
+ Returns
+ -------
+ None
+ """
self._permissions.delete(item, capability_item)
@api(version="3.5")
- def populate_dqw(self, item):
+ def populate_dqw(self, item) -> None:
+ """
+ Get information about the data quality warning for the database, table,
+ column, published data source, flow, virtual connection, or virtual
+ connection table.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_dqws
+
+ Parameters
+ ----------
+ item : DatasourceItem
+ The datasource item to populate data quality warnings for.
+
+ Returns
+ -------
+ None
+ """
self._data_quality_warnings.populate(item)
@api(version="3.5")
- def update_dqw(self, item, warning):
+ def update_dqw(self, item: DatasourceItem, warning: "DQWItem") -> list["DQWItem"]:
+ """
+ Update the warning type, status, and message of a data quality warning.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#update_dqw
+
+ Parameters
+ ----------
+ item : DatasourceItem
+ The datasource item to update data quality warnings for.
+
+ warning : DQWItem
+ The data quality warning to update.
+
+ Returns
+ -------
+ DQWItem
+ The updated data quality warning.
+ """
return self._data_quality_warnings.update(item, warning)
@api(version="3.5")
- def add_dqw(self, item, warning):
+ def add_dqw(self, item: DatasourceItem, warning: "DQWItem") -> list["DQWItem"]:
+ """
+ Add a data quality warning to a datasource.
+
+ The Add Data Quality Warning method adds a data quality warning to an
+ asset. (An automatically-generated monitoring warning does not count
+ towards this limit.) In Tableau Cloud February 2024 and Tableau Server
+ 2024.2 and earlier, adding a data quality warning to an asset that
+ already has one causes an error.
+
+ This method is available if your Tableau Cloud site or Tableau Server is licensed with Data Management.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#add_dqw
+
+ Parameters
+ ----------
+ item: DatasourceItem
+ The datasource item to add data quality warnings to.
+
+ warning: DQWItem
+ The data quality warning to add.
+
+ Returns
+ -------
+ DQWItem
+ The added data quality warning.
+
+ """
return self._data_quality_warnings.add(item, warning)
@api(version="3.5")
- def delete_dqw(self, item):
+ def delete_dqw(self, item: DatasourceItem) -> None:
+ """
+ Delete a data quality warnings from an asset.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#delete_dqws
+
+ Parameters
+ ----------
+ item: DatasourceItem
+ The datasource item to delete data quality warnings from.
+
+ Returns
+ -------
+ None
+ """
self._data_quality_warnings.clear(item)
+
+ # Populate datasource item's revisions
+ @api(version="2.3")
+ def populate_revisions(self, datasource_item: DatasourceItem) -> None:
+ """
+ Retrieve revision information for the specified datasource item.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#get_data_source_revisions
+
+ Parameters
+ ----------
+ datasource_item : DatasourceItem
+ The datasource item to retrieve revisions for.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ MissingRequiredFieldError
+ If the datasource item is missing an ID.
+ """
+ if not datasource_item.id:
+ error = "Datasource item missing ID. Datasource must be retrieved from server first."
+ raise MissingRequiredFieldError(error)
+
+ def revisions_fetcher():
+ return self._get_datasource_revisions(datasource_item)
+
+ datasource_item._set_revisions(revisions_fetcher)
+ logger.info(f"Populated revisions for datasource (ID: {datasource_item.id})")
+
+ def _get_datasource_revisions(
+ self, datasource_item: DatasourceItem, req_options: Optional["RequestOptions"] = None
+ ) -> list[RevisionItem]:
+ url = f"{self.baseurl}/{datasource_item.id}/revisions"
+ server_response = self.get_request(url, req_options)
+ revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, datasource_item)
+ return revisions
+
+ # Download 1 datasource revision by revision number
+ @api(version="2.3")
+ def download_revision(
+ self,
+ datasource_id: str,
+ revision_number: Optional[str],
+ filepath: Optional[PathOrFileW] = None,
+ include_extract: bool = True,
+ ) -> PathOrFileW:
+ """
+ Downloads a specific version of a data source prior to the current one
+ in .tdsx format. To download the current version of a data source set
+ the revision number to None.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#download_data_source_revision
+
+ Parameters
+ ----------
+ datasource_id : str
+ The unique ID of the datasource to download.
+
+ revision_number : Optional[str]
+ The revision number of the data source to download. To determine
+ what versions are available, call the `populate_revisions` method.
+ Pass None to download the current version.
+
+ filepath : Optional[PathOrFileW]
+ The file path to save the downloaded datasource to. If not
+ specified, the file will be saved to the current working directory.
+
+ include_extract : bool, default True
+ If True, the extract is included in the download. If False, the
+ extract is not included.
+
+ Returns
+ -------
+ filepath : PathOrFileW
+ """
+ if not datasource_id:
+ error = "Datasource ID undefined."
+ raise ValueError(error)
+ if revision_number is None:
+ url = f"{self.baseurl}/{datasource_id}/content"
+ else:
+ url = f"{self.baseurl}/{datasource_id}/revisions/{revision_number}/content"
+
+ if not include_extract:
+ url += "?includeExtract=False"
+
+ with closing(self.get_request(url, parameters={"stream": True})) as server_response:
+ m = Message()
+ m["Content-Disposition"] = server_response.headers["Content-Disposition"]
+ params = m.get_filename(failobj="")
+ if isinstance(filepath, io_types_w):
+ for chunk in server_response.iter_content(1024): # 1KB
+ filepath.write(chunk)
+ return_path = filepath
+ else:
+ params = fix_filename(params)
+ filename = to_filename(os.path.basename(params))
+ download_path = make_download_path(filepath, filename)
+ with open(download_path, "wb") as f:
+ for chunk in server_response.iter_content(1024): # 1KB
+ f.write(chunk)
+ return_path = os.path.abspath(download_path)
+
+ logger.info(f"Downloaded datasource revision {revision_number} to {return_path} (ID: {datasource_id})")
+ return return_path
+
+ @api(version="2.3")
+ def delete_revision(self, datasource_id: str, revision_number: str) -> None:
+ """
+ Removes a specific version of a data source from the specified site.
+
+ The content is removed, and the specified revision can no longer be
+ downloaded using Download Data Source Revision. If you call Get Data
+ Source Revisions, the revision that's been removed is listed with the
+ attribute is_deleted=True.
+
+ REST API:https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#remove_data_source_revision
+
+ Parameters
+ ----------
+ datasource_id : str
+ The unique ID of the datasource to delete.
+
+ revision_number : str
+ The revision number of the data source to delete.
+
+ Returns
+ -------
+ None
+ """
+ if datasource_id is None or revision_number is None:
+ raise ValueError
+ url = "/".join([self.baseurl, datasource_id, "revisions", revision_number])
+
+ self.delete_request(url)
+ logger.info(f"Deleted single datasource revision (ID: {datasource_id}) (Revision: {revision_number})")
+
+ # a convenience method
+ @api(version="2.8")
+ def schedule_extract_refresh(
+ self, schedule_id: str, item: DatasourceItem
+ ) -> list["AddResponse"]: # actually should return a task
+ """
+ Adds a task to refresh a data source to an existing server schedule on
+ Tableau Server.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#add_data_source_to_schedule
+
+ Parameters
+ ----------
+ schedule_id : str
+ The unique ID of the schedule to add the task to.
+
+ item : DatasourceItem
+ The datasource item to add to the schedule.
+
+ Returns
+ -------
+ list[AddResponse]
+ """
+ return self.parent_srv.schedules.add_to_schedule(schedule_id, datasource=item)
+
+ @api(version="1.0")
+ def add_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> set[str]:
+ """
+ Adds one or more tags to the specified datasource item.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#add_tags_to_data_source
+
+ Parameters
+ ----------
+ item : Union[DatasourceItem, str]
+ The datasource item or ID to add tags to.
+
+ tags : Union[Iterable[str], str]
+ The tag or tags to add to the datasource item.
+
+ Returns
+ -------
+ set[str]
+ The updated set of tags on the datasource item.
+ """
+ return super().add_tags(item, tags)
+
+ @api(version="1.0")
+ def delete_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> None:
+ """
+ Deletes one or more tags from the specified datasource item.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#delete_tag_from_data_source
+
+ Parameters
+ ----------
+ item : Union[DatasourceItem, str]
+ The datasource item or ID to delete tags from.
+
+ tags : Union[Iterable[str], str]
+ The tag or tags to delete from the datasource item.
+
+ Returns
+ -------
+ None
+ """
+ return super().delete_tags(item, tags)
+
+ @api(version="1.0")
+ def update_tags(self, item: DatasourceItem) -> None:
+ """
+ Updates the tags on the server to match the specified datasource item.
+
+ Parameters
+ ----------
+ item : DatasourceItem
+ The datasource item to update tags for.
+
+ Returns
+ -------
+ None
+ """
+ return super().update_tags(item)
+
+ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[DatasourceItem]:
+ """
+ Queries the Tableau Server for items using the specified filters. Page
+ size can be specified to limit the number of items returned in a single
+ request. If not specified, the default page size is 100. Page size can
+ be an integer between 1 and 1000.
+
+ No positional arguments are allowed. All filters must be specified as
+ keyword arguments. If you use the equality operator, you can specify it
+ through =. If you want to use a different operator,
+ you can specify it through __=. Field
+ names can either be in snake_case or camelCase.
+
+ This endpoint supports the following fields and operators:
+
+
+ authentication_type=...
+ authentication_type__in=...
+ connected_workbook_type=...
+ connected_workbook_type__gt=...
+ connected_workbook_type__gte=...
+ connected_workbook_type__lt=...
+ connected_workbook_type__lte=...
+ connection_to=...
+ connection_to__in=...
+ connection_type=...
+ connection_type__in=...
+ content_url=...
+ content_url__in=...
+ created_at=...
+ created_at__gt=...
+ created_at__gte=...
+ created_at__lt=...
+ created_at__lte=...
+ database_name=...
+ database_name__in=...
+ database_user_name=...
+ database_user_name__in=...
+ description=...
+ description__in=...
+ favorites_total=...
+ favorites_total__gt=...
+ favorites_total__gte=...
+ favorites_total__lt=...
+ favorites_total__lte=...
+ has_alert=...
+ has_embedded_password=...
+ has_extracts=...
+ is_certified=...
+ is_connectable=...
+ is_default_port=...
+ is_hierarchical=...
+ is_published=...
+ name=...
+ name__in=...
+ owner_domain=...
+ owner_domain__in=...
+ owner_email=...
+ owner_name=...
+ owner_name__in=...
+ project_name=...
+ project_name__in=...
+ server_name=...
+ server_name__in=...
+ server_port=...
+ size=...
+ size__gt=...
+ size__gte=...
+ size__lt=...
+ size__lte=...
+ table_name=...
+ table_name__in=...
+ tags=...
+ tags__in=...
+ type=...
+ updated_at=...
+ updated_at__gt=...
+ updated_at__gte=...
+ updated_at__lt=...
+ updated_at__lte=...
+ """
+
+ return super().filter(*invalid, page_size=page_size, **kwargs)
diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py
index 1cfa41733..499324e8e 100644
--- a/tableauserverclient/server/endpoint/default_permissions_endpoint.py
+++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py
@@ -1,42 +1,58 @@
import logging
-from .. import RequestFactory
-from ...models import PermissionsRule
-
from .endpoint import Endpoint
from .exceptions import MissingRequiredFieldError
+from tableauserverclient.server import RequestFactory
+from tableauserverclient.models import DatabaseItem, PermissionsRule, ProjectItem, plural_type, Resource
+from typing import TYPE_CHECKING, Callable, Optional, Union
+from collections.abc import Sequence
+
+if TYPE_CHECKING:
+ from ..server import Server
+ from ..request_options import RequestOptions
+from tableauserverclient.helpers.logging import logger
-logger = logging.getLogger(__name__)
+# these are the only two items that can hold default permissions for another type
+BaseItem = Union[DatabaseItem, ProjectItem]
class _DefaultPermissionsEndpoint(Endpoint):
- """Adds default-permission model to another endpoint
+ """Adds default-permission model to an existing database or project
- Tableau default-permissions model applies only to databases and projects
- and then takes an object type in the uri to set the defaults.
- This class is meant to be instantated inside a parent endpoint which
+ Tableau default-permissions model takes an object type in the uri to set the defaults.
+ This class is meant to be instantiated inside a parent endpoint which
has these supported endpoints
"""
- def __init__(self, parent_srv, owner_baseurl):
- super(_DefaultPermissionsEndpoint, self).__init__(parent_srv)
+ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> None:
+ super().__init__(parent_srv)
- # owner_baseurl is the baseurl of the parent. The MUST be a lambda
- # since we don't know the full site URL until we sign in. If
- # populated without, we will get a sign-in error
+ # owner_baseurl is the baseurl of the parent, a project or database.
+ # It MUST be a lambda since we don't know the full site URL until we sign in.
+ # If populated without, we will get a sign-in error
self.owner_baseurl = owner_baseurl
- def update_default_permissions(self, resource, permissions, content_type):
- url = "{0}/{1}/default-permissions/{2}".format(self.owner_baseurl(), resource.id, content_type + "s")
+ def __str__(self):
+ return f""
+
+ __repr__ = __str__
+
+ def update_default_permissions(
+ self, resource: BaseItem, permissions: Sequence[PermissionsRule], content_type: Union[Resource, str]
+ ) -> list[PermissionsRule]:
+ url = f"{self.owner_baseurl()}/{resource.id}/default-permissions/{plural_type(content_type)}"
update_req = RequestFactory.Permission.add_req(permissions)
response = self.put_request(url, update_req)
permissions = PermissionsRule.from_response(response.content, self.parent_srv.namespace)
- logger.info("Updated permissions for resource {0}".format(resource.id))
+ logger.info(f"Updated default {content_type} permissions for resource {resource.id}")
+ logger.info(permissions)
return permissions
- def delete_default_permission(self, resource, rule, content_type):
+ def delete_default_permission(
+ self, resource: BaseItem, rule: PermissionsRule, content_type: Union[Resource, str]
+ ) -> None:
for capability, mode in rule.capabilities.items():
# Made readability better but line is too long, will make this look better
url = (
@@ -44,7 +60,7 @@ def delete_default_permission(self, resource, rule, content_type):
"{content_type}/{grantee_type}/{grantee_id}/{cap}/{mode}".format(
baseurl=self.owner_baseurl(),
content_id=resource.id,
- content_type=content_type + "s",
+ content_type=plural_type(content_type),
grantee_type=rule.grantee.tag_name + "s",
grantee_id=rule.grantee.id,
cap=capability,
@@ -52,28 +68,28 @@ def delete_default_permission(self, resource, rule, content_type):
)
)
- logger.debug("Removing {0} permission for capabilty {1}".format(mode, capability))
+ logger.debug(f"Removing {mode} permission for capability {capability}")
self.delete_request(url)
- logger.info(
- "Deleted permission for {0} {1} item {2}".format(rule.grantee.tag_name, rule.grantee.id, resource.id)
- )
+ logger.info(f"Deleted permission for {rule.grantee.tag_name} {rule.grantee.id} item {resource.id}")
- def populate_default_permissions(self, item, content_type):
+ def populate_default_permissions(self, item: BaseItem, content_type: Union[Resource, str]) -> None:
if not item.id:
error = "Server item is missing ID. Item must be retrieved from server first."
raise MissingRequiredFieldError(error)
- def permission_fetcher():
+ def permission_fetcher() -> list[PermissionsRule]:
return self._get_default_permissions(item, content_type)
item._set_default_permissions(permission_fetcher, content_type)
- logger.info("Populated {0} permissions for item (ID: {1})".format(item.id, content_type))
+ logger.info(f"Populated default {content_type} permissions for item (ID: {item.id})")
- def _get_default_permissions(self, item, content_type, req_options=None):
- url = "{0}/{1}/default-permissions/{2}".format(self.owner_baseurl(), item.id, content_type + "s")
+ def _get_default_permissions(
+ self, item: BaseItem, content_type: Union[Resource, str], req_options: Optional["RequestOptions"] = None
+ ) -> list[PermissionsRule]:
+ url = f"{self.owner_baseurl()}/{item.id}/default-permissions/{plural_type(content_type)}"
server_response = self.get_request(url, req_options)
permissions = PermissionsRule.from_response(server_response.content, self.parent_srv.namespace)
-
+ logger.info({"content_type": content_type, "permissions": permissions})
return permissions
diff --git a/tableauserverclient/server/endpoint/dqw_endpoint.py b/tableauserverclient/server/endpoint/dqw_endpoint.py
index e19ca7d90..d2ad517ee 100644
--- a/tableauserverclient/server/endpoint/dqw_endpoint.py
+++ b/tableauserverclient/server/endpoint/dqw_endpoint.py
@@ -1,48 +1,57 @@
import logging
-
-from .. import RequestFactory, DQWItem
+from typing import Callable, Optional, Protocol, TYPE_CHECKING
from .endpoint import Endpoint
from .exceptions import MissingRequiredFieldError
+from tableauserverclient.server import RequestFactory
+from tableauserverclient.models import DQWItem
+
+from tableauserverclient.helpers.logging import logger
+if TYPE_CHECKING:
+ from tableauserverclient.server.request_options import RequestOptions
-logger = logging.getLogger(__name__)
+
+class HasId(Protocol):
+ @property
+ def id(self) -> Optional[str]: ...
+ def _set_data_quality_warnings(self, dqw: Callable[[], list[DQWItem]]): ...
class _DataQualityWarningEndpoint(Endpoint):
def __init__(self, parent_srv, resource_type):
- super(_DataQualityWarningEndpoint, self).__init__(parent_srv)
+ super().__init__(parent_srv)
self.resource_type = resource_type
@property
- def baseurl(self):
- return "{0}/sites/{1}/dataQualityWarnings/{2}".format(
+ def baseurl(self) -> str:
+ return "{}/sites/{}/dataQualityWarnings/{}".format(
self.parent_srv.baseurl, self.parent_srv.site_id, self.resource_type
)
- def add(self, resource, warning):
- url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=resource.id)
+ def add(self, resource: HasId, warning: DQWItem) -> list[DQWItem]:
+ url = f"{self.baseurl}/{resource.id}"
add_req = RequestFactory.DQW.add_req(warning)
response = self.post_request(url, add_req)
warnings = DQWItem.from_response(response.content, self.parent_srv.namespace)
- logger.info("Added dqw for resource {0}".format(resource.id))
+ logger.info(f"Added dqw for resource {resource.id}")
return warnings
- def update(self, resource, warning):
- url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=resource.id)
+ def update(self, resource: HasId, warning: DQWItem) -> list[DQWItem]:
+ url = f"{self.baseurl}/{resource.id}"
add_req = RequestFactory.DQW.update_req(warning)
response = self.put_request(url, add_req)
warnings = DQWItem.from_response(response.content, self.parent_srv.namespace)
- logger.info("Added dqw for resource {0}".format(resource.id))
+ logger.info(f"Added dqw for resource {resource.id}")
return warnings
- def clear(self, resource):
- url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=resource.id)
+ def clear(self, resource: HasId) -> None:
+ url = f"{self.baseurl}/{resource.id}"
return self.delete_request(url)
- def populate(self, item):
+ def populate(self, item: HasId) -> None:
if not item.id:
error = "Server item is missing ID. Item must be retrieved from server first."
raise MissingRequiredFieldError(error)
@@ -51,10 +60,10 @@ def dqw_fetcher():
return self._get_data_quality_warnings(item)
item._set_data_quality_warnings(dqw_fetcher)
- logger.info("Populated permissions for item (ID: {0})".format(item.id))
+ logger.info(f"Populated permissions for item (ID: {item.id})")
- def _get_data_quality_warnings(self, item, req_options=None):
- url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=item.id)
+ def _get_data_quality_warnings(self, item: HasId, req_options: Optional["RequestOptions"] = None) -> list[DQWItem]:
+ url = f"{self.baseurl}/{item.id}"
server_response = self.get_request(url, req_options)
dqws = DQWItem.from_response(server_response.content, self.parent_srv.namespace)
diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py
index 31291abc9..21462af5f 100644
--- a/tableauserverclient/server/endpoint/endpoint.py
+++ b/tableauserverclient/server/endpoint/endpoint.py
@@ -1,101 +1,194 @@
-from .exceptions import (
+from typing_extensions import Concatenate, ParamSpec
+from tableauserverclient import datetime_helpers as datetime
+
+import abc
+from packaging.version import Version
+from functools import wraps
+from xml.etree.ElementTree import ParseError
+from typing import (
+ Any,
+ Callable,
+ Generic,
+ Optional,
+ TYPE_CHECKING,
+ TypeVar,
+ Union,
+)
+from typing_extensions import Self
+
+from tableauserverclient.models.pagination_item import PaginationItem
+from tableauserverclient.server.request_options import RequestOptions
+
+from tableauserverclient.server.endpoint.exceptions import (
+ FailedSignInError,
ServerResponseError,
InternalServerError,
NonXMLResponseError,
- EndpointUnavailableError,
+ NotSignedInError,
)
-from functools import wraps
-from xml.etree.ElementTree import ParseError
-from ..query import QuerySet
-import logging
+from tableauserverclient.server.exceptions import EndpointUnavailableError
+
+from tableauserverclient.server.query import QuerySet
+from tableauserverclient import helpers, get_versions
+
+from tableauserverclient.helpers.logging import logger
-try:
- from distutils2.version import NormalizedVersion as Version
-except ImportError:
- from distutils.version import LooseVersion as Version
+if TYPE_CHECKING:
+ from tableauserverclient.server.server import Server
+ from requests import Response
-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):
+CONTENT_TYPE_HEADER = "content-type"
+TABLEAU_AUTH_HEADER = "x-tableau-auth"
+USER_AGENT_HEADER = "User-Agent"
+
+
+class Endpoint:
+ def __init__(self, parent_srv: "Server"):
self.parent_srv = parent_srv
+ async_response = None
+
@staticmethod
- def _make_common_headers(auth_token, content_type):
- headers = {}
+ def set_parameters(http_options, auth_token, content, content_type, parameters) -> dict[str, Any]:
+ parameters = parameters or {}
+ parameters.update(http_options)
+ if "headers" not in parameters:
+ parameters["headers"] = {}
+
if auth_token is not None:
- headers["x-tableau-auth"] = auth_token
+ parameters["headers"][TABLEAU_AUTH_HEADER] = auth_token
if content_type is not None:
- headers["content-type"] = content_type
+ parameters["headers"][CONTENT_TYPE_HEADER] = content_type
- return headers
+ Endpoint.set_user_agent(parameters)
+ if content is not None:
+ parameters["data"] = content
+ return parameters or {}
@staticmethod
- def _safe_to_log(server_response):
- """Checks if the server_response content is not xml (eg binary image or zip)
- and replaces it with a constant
- """
- ALLOWED_CONTENT_TYPES = ("application/xml", "application/xml;charset=utf-8")
- if server_response.headers.get("Content-Type", None) not in ALLOWED_CONTENT_TYPES:
- return "[Truncated File Contents]"
- else:
- return server_response.content
+ def set_user_agent(parameters):
+ if "headers" not in parameters:
+ parameters["headers"] = {}
+ if USER_AGENT_HEADER not in parameters["headers"]:
+ if USER_AGENT_HEADER in parameters:
+ parameters["headers"][USER_AGENT_HEADER] = parameters[USER_AGENT_HEADER]
+ else:
+ # only set the TSC user agent if not already populated
+ _client_version: Optional[str] = get_versions()["version"]
+ parameters["headers"][USER_AGENT_HEADER] = f"Tableau Server Client/{_client_version}"
+
+ # result: parameters["headers"]["User-Agent"] is set
+ # return explicitly for testing only
+ return parameters
+
+ def _blocking_request(self, method, url, parameters={}) -> Optional[Union["Response", Exception]]:
+ response = None
+ logger.debug(f"[{datetime.timestamp()}] Begin blocking request to {url}")
+ try:
+ response = method(url, **parameters)
+ logger.debug(f"[{datetime.timestamp()}] Call finished")
+ except Exception as e:
+ logger.debug(f"Error making request to server: {e}")
+ raise e
+ return response
+
+ def send_request_while_show_progress_threaded(
+ self, method, url, parameters={}, request_timeout=None
+ ) -> Optional[Union["Response", Exception]]:
+ return self._blocking_request(method, url, parameters)
def _make_request(
self,
- method,
- url,
- content=None,
- auth_token=None,
- content_type=None,
- parameters=None,
- ):
- parameters = parameters or {}
- parameters.update(self.parent_srv.http_options)
- if not "headers" in parameters:
- parameters["headers"] = {}
- parameters["headers"].update(Endpoint._make_common_headers(auth_token, content_type))
-
- if content is not None:
- parameters["data"] = content
+ method: Callable[..., "Response"],
+ url: str,
+ content: Optional[bytes] = None,
+ auth_token: Optional[str] = None,
+ content_type: Optional[str] = None,
+ parameters: Optional[dict[str, Any]] = None,
+ ) -> "Response":
+ parameters = Endpoint.set_parameters(
+ self.parent_srv.http_options, auth_token, content, content_type, parameters
+ )
- logger.debug(u"request {}, url: {}".format(method.__name__, url))
+ logger.debug(f"request method {method.__name__}, url: {url}")
if content:
- logger.debug(u"request content: {}".format(content[:1000]))
-
- server_response = method(url, **parameters)
- self.parent_srv._namespace.detect(server_response.content)
- self._check_status(server_response)
+ redacted = helpers.strings.redact_xml(content[:200])
+ # this needs to be under a trace or something, it's a LOT
+ # logger.debug("request content: {}".format(redacted))
+
+ # a request can, for stuff like publishing, spin for ages waiting for a response.
+ # we need some user-facing activity so they know it's not dead.
+ request_timeout = self.parent_srv.http_options.get("timeout") or 0
+ server_response: Optional[Union["Response", Exception]] = self.send_request_while_show_progress_threaded(
+ method, url, parameters, request_timeout
+ )
+ logger.debug(f"[{datetime.timestamp()}] Async request returned: received {server_response}")
+ # is this blocking retry really necessary? I guess if it was just the threading messing it up?
+ if server_response is None:
+ logger.debug(server_response)
+ logger.debug(f"[{datetime.timestamp()}] Async request failed: retrying")
+ server_response = self._blocking_request(method, url, parameters)
+ if server_response is None:
+ logger.debug(f"[{datetime.timestamp()}] Request failed")
+ raise RuntimeError
+ if isinstance(server_response, Exception):
+ raise server_response
+ self._check_status(server_response, url)
+
+ loggable_response = self.log_response_safely(server_response)
+ logger.debug(f"Server response from {url}")
+ # uncomment the following to log full responses in debug mode
+ # BE CAREFUL WHEN SHARING THESE RESULTS - MAY CONTAIN YOUR SENSITIVE DATA
+ # logger.debug(loggable_response)
+
+ if content_type == "application/xml":
+ self.parent_srv._namespace.detect(server_response.content)
- # This check is to determine if the response is a text response (xml or otherwise)
- # so that we do not attempt to log bytes and other binary data.
- 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)
- )
- )
return server_response
- def _check_status(self, server_response):
+ def _check_status(self, server_response: "Response", url: Optional[str] = None):
+ logger.debug(f"Response status: {server_response}")
+ if not hasattr(server_response, "status_code"):
+ raise OSError("Response is not a http response?")
if server_response.status_code >= 500:
- raise InternalServerError(server_response)
+ raise InternalServerError(server_response, url)
elif server_response.status_code not in Success_codes:
try:
- raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace)
+ if server_response.status_code == 401:
+ # TODO: catch this in server.py and attempt to sign in again, in case it's a session expiry
+ raise FailedSignInError.from_response(server_response.content, self.parent_srv.namespace, url)
+
+ raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace, url)
except ParseError:
- # This will happen if we get a non-success HTTP code that
- # doesn't return an xml error object (like metadata endpoints)
- # we convert this to a better exception and pass through the raw
- # response body
+ # This will happen if we get a non-success HTTP code that doesn't return an xml error object
+ # e.g. metadata endpoints, 503 pages, totally different servers
+ # we convert this to a better exception and pass through the raw response body
raise NonXMLResponseError(server_response.content)
except Exception:
# anything else re-raise here
raise
+ def log_response_safely(self, server_response: "Response") -> str:
+ # Checking the content type header prevents eager evaluation of streaming requests.
+ content_type = server_response.headers.get("Content-Type")
+
+ # Response.content is a property. Calling it will load the entire response into memory. Checking if the
+ # content-type is an octet-stream accomplishes the same goal without eagerly loading content.
+ # This check is to determine if the response is a text response (xml or otherwise)
+ # so that we do not attempt to log bytes and other binary data.
+ loggable_response = f"Content type `{content_type}`"
+ if content_type == "application/octet-stream":
+ loggable_response = f"A stream of type {content_type} [Truncated File Contents]"
+ elif server_response.encoding and len(server_response.content) > 0:
+ loggable_response = helpers.strings.redact_xml(server_response.content.decode(server_response.encoding))
+ return loggable_response
+
def get_unauthenticated_request(self, url):
return self._make_request(self.parent_srv.session.get, url)
@@ -103,7 +196,7 @@ def get_request(self, url, request_object=None, parameters=None):
if request_object is not None:
try:
# Query param delimiters don't need to be encoded for versions before 3.7 (2020.1)
- self.parent_srv.assert_at_least_version("3.7")
+ self.parent_srv.assert_at_least_version("3.7", "Query param encoding")
parameters = parameters or {}
parameters["params"] = request_object.get_query_params()
except EndpointUnavailableError:
@@ -117,10 +210,10 @@ def get_request(self, url, request_object=None, parameters=None):
)
def delete_request(self, url):
- # We don't return anything for a delete
+ # We don't return anything for a delete request
self._make_request(self.parent_srv.session.delete, url, auth_token=self.parent_srv.auth_token)
- def put_request(self, url, xml_request=None, content_type="text/xml", parameters=None):
+ def put_request(self, url, xml_request=None, content_type=XML_CONTENT_TYPE, parameters=None):
return self._make_request(
self.parent_srv.session.put,
url,
@@ -130,7 +223,7 @@ def put_request(self, url, xml_request=None, content_type="text/xml", parameters
parameters=parameters,
)
- def post_request(self, url, xml_request, content_type="text/xml", parameters=None):
+ def post_request(self, url, xml_request, content_type=XML_CONTENT_TYPE, parameters=None):
return self._make_request(
self.parent_srv.session.post,
url,
@@ -140,7 +233,7 @@ def post_request(self, url, xml_request, content_type="text/xml", parameters=Non
parameters=parameters,
)
- def patch_request(self, url, xml_request, content_type="text/xml", parameters=None):
+ def patch_request(self, url, xml_request, content_type=XML_CONTENT_TYPE, parameters=None):
return self._make_request(
self.parent_srv.session.patch,
url,
@@ -151,7 +244,12 @@ def patch_request(self, url, xml_request, content_type="text/xml", parameters=No
)
-def api(version):
+E = TypeVar("E", bound="Endpoint")
+P = ParamSpec("P")
+R = TypeVar("R")
+
+
+def api(version: str) -> Callable[[Callable[Concatenate[E, P], R]], Callable[Concatenate[E, P], R]]:
"""Annotate the minimum supported version for an endpoint.
Checks the version on the server object and compares normalized versions.
@@ -170,10 +268,10 @@ def api(version):
>>> ...
"""
- def _decorator(func):
+ def _decorator(func: Callable[Concatenate[E, P], R]) -> Callable[Concatenate[E, P], R]:
@wraps(func)
- def wrapper(self, *args, **kwargs):
- self.parent_srv.assert_at_least_version(version)
+ def wrapper(self: E, *args: P.args, **kwargs: P.kwargs) -> R:
+ self.parent_srv.assert_at_least_version(version, self.__class__.__name__)
return func(self, *args, **kwargs)
return wrapper
@@ -181,7 +279,7 @@ def wrapper(self, *args, **kwargs):
return _decorator
-def parameter_added_in(**params):
+def parameter_added_in(**params: str) -> Callable[[Callable[Concatenate[E, P], R]], Callable[Concatenate[E, P], R]]:
"""Annotate minimum versions for new parameters or request options on an endpoint.
The api decorator documents when an endpoint was added, this decorator annotates
@@ -204,9 +302,9 @@ def parameter_added_in(**params):
>>> ...
"""
- def _decorator(func):
+ def _decorator(func: Callable[Concatenate[E, P], R]) -> Callable[Concatenate[E, P], R]:
@wraps(func)
- def wrapper(self, *args, **kwargs):
+ def wrapper(self: E, *args: P.args, **kwargs: P.kwargs) -> R:
import warnings
server_ver = Version(self.parent_srv.version or "0.0")
@@ -214,7 +312,7 @@ def wrapper(self, *args, **kwargs):
for p in params_to_check:
min_ver = Version(str(params[p]))
if server_ver < min_ver:
- error = "{!r} not available in {}, it will be ignored. Added in {}".format(p, server_ver, min_ver)
+ error = f"{p!r} not available in {server_ver}, it will be ignored. Added in {min_ver}"
warnings.warn(error)
return func(self, *args, **kwargs)
@@ -223,23 +321,74 @@ def wrapper(self, *args, **kwargs):
return _decorator
-class QuerysetEndpoint(Endpoint):
+T = TypeVar("T")
+
+
+class QuerysetEndpoint(Endpoint, Generic[T]):
@api(version="2.0")
- def all(self, *args, **kwargs):
- queryset = QuerySet(self)
+ def all(self, *args, page_size: Optional[int] = None, **kwargs) -> QuerySet[T]:
+ if args or kwargs:
+ raise ValueError(".all method takes no arguments.")
+ queryset = QuerySet(self, page_size=page_size)
return queryset
@api(version="2.0")
- def filter(self, *args, **kwargs):
- queryset = QuerySet(self).filter(**kwargs)
+ def filter(self, *_, page_size: Optional[int] = None, **kwargs) -> QuerySet[T]:
+ if _:
+ raise RuntimeError("Only keyword arguments accepted.")
+ queryset = QuerySet(self, page_size=page_size).filter(**kwargs)
return queryset
@api(version="2.0")
- def order_by(self, *args, **kwargs):
+ def order_by(self, *args, **kwargs) -> QuerySet[T]:
+ if kwargs:
+ raise ValueError(".order_by does not accept keyword arguments.")
queryset = QuerySet(self).order_by(*args)
return queryset
@api(version="2.0")
- def paginate(self, **kwargs):
+ def paginate(self, **kwargs) -> QuerySet[T]:
queryset = QuerySet(self).paginate(**kwargs)
return queryset
+
+ @abc.abstractmethod
+ def get(self, request_options: Optional[RequestOptions] = None) -> tuple[list[T], PaginationItem]:
+ raise NotImplementedError(f".get has not been implemented for {self.__class__.__qualname__}")
+
+ def fields(self: Self, *fields: str) -> QuerySet:
+ """
+ Add fields to the request options. If no fields are provided, the
+ default fields will be used. If fields are provided, the default fields
+ will be used in addition to the provided fields.
+
+ Parameters
+ ----------
+ fields : str
+ The fields to include in the request options.
+
+ Returns
+ -------
+ QuerySet
+ """
+ queryset = QuerySet(self)
+ queryset.request_options.fields |= set(fields) | set(("_default_",))
+ return queryset
+
+ def only_fields(self: Self, *fields: str) -> QuerySet:
+ """
+ Add fields to the request options. If no fields are provided, the
+ default fields will be used. If fields are provided, the default fields
+ will be replaced by the provided fields.
+
+ Parameters
+ ----------
+ fields : str
+ The fields to include in the request options.
+
+ Returns
+ -------
+ QuerySet
+ """
+ queryset = QuerySet(self)
+ queryset.request_options.fields |= set(fields)
+ return queryset
diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py
index 48dcaf4c8..ee931c910 100644
--- a/tableauserverclient/server/endpoint/exceptions.py
+++ b/tableauserverclient/server/endpoint/exceptions.py
@@ -1,62 +1,85 @@
-import xml.etree.ElementTree as ET
+from defusedxml.ElementTree import fromstring
+from typing import Mapping, Optional, TypeVar
-class ServerResponseError(Exception):
- def __init__(self, code, summary, detail):
+def split_pascal_case(s: str) -> str:
+ return "".join([f" {c}" if c.isupper() else c for c in s]).strip()
+
+
+class TableauError(Exception):
+ pass
+
+
+T = TypeVar("T")
+
+
+class XMLError(TableauError):
+ def __init__(self, code: str, summary: str, detail: str, url: Optional[str] = None) -> None:
self.code = code
self.summary = summary
self.detail = detail
- super(ServerResponseError, self).__init__(str(self))
+ self.url = url
+ super().__init__(str(self))
def __str__(self):
- return "\n\n\t{0}: {1}\n\t\t{2}".format(self.code, self.summary, self.detail)
+ return f"\n\n\t{self.code}: {self.summary}\n\t\t{self.detail}"
@classmethod
- def from_response(cls, resp, ns):
+ def from_response(cls, resp, ns, url):
# Check elements exist before .text
- parsed_response = ET.fromstring(resp)
- error_response = cls(
- parsed_response.find("t:error", namespaces=ns).get("code", ""),
- parsed_response.find(".//t:summary", namespaces=ns).text,
- parsed_response.find(".//t:detail", namespaces=ns).text,
- )
+ parsed_response = fromstring(resp)
+ try:
+ error_response = cls(
+ parsed_response.find("t:error", namespaces=ns).get("code", ""),
+ parsed_response.find(".//t:summary", namespaces=ns).text,
+ parsed_response.find(".//t:detail", namespaces=ns).text,
+ url,
+ )
+ except Exception as e:
+ raise NonXMLResponseError(resp)
return error_response
-class InternalServerError(Exception):
- def __init__(self, server_response):
+class ServerResponseError(XMLError):
+ pass
+
+
+class InternalServerError(TableauError):
+ def __init__(self, server_response, request_url: Optional[str] = None):
self.code = server_response.status_code
self.content = server_response.content
+ self.url = request_url or "server"
def __str__(self):
- return "\n\nError status code: {0}\n{1}".format(self.code, self.content)
+ return f"\n\nInternal error {self.code} at {self.url}\n{self.content}"
-class MissingRequiredFieldError(Exception):
+class MissingRequiredFieldError(TableauError):
pass
-class ServerInfoEndpointNotFoundError(Exception):
+class NotSignedInError(TableauError):
pass
-class EndpointUnavailableError(Exception):
- pass
+class FailedSignInError(XMLError, NotSignedInError):
+ def __str__(self):
+ return f"{split_pascal_case(self.__class__.__name__)}: {super().__str__()}"
-class ItemTypeNotAllowed(Exception):
+class ItemTypeNotAllowed(TableauError):
pass
-class NonXMLResponseError(Exception):
+class NonXMLResponseError(TableauError):
pass
-class InvalidGraphQLQuery(Exception):
+class InvalidGraphQLQuery(TableauError):
pass
-class GraphQLError(Exception):
+class GraphQLError(TableauError):
def __init__(self, error_payload):
self.error = error_payload
@@ -66,18 +89,20 @@ def __str__(self):
return pformat(self.error)
-class JobFailedException(Exception):
+class JobFailedException(TableauError):
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):
+
+
+class FlowRunFailedException(TableauError):
def __init__(self, flow_run):
self.background_job_id = flow_run.background_job_id
self.flow_run = flow_run
@@ -87,4 +112,8 @@ def __str__(self):
class FlowRunCancelledException(FlowRunFailedException):
- pass
+ pass
+
+
+class UnsupportedAttributeError(TableauError):
+ pass
diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py
index 459d852e6..8330e6d2c 100644
--- a/tableauserverclient/server/endpoint/favorites_endpoint.py
+++ b/tableauserverclient/server/endpoint/favorites_endpoint.py
@@ -1,77 +1,135 @@
-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 tableauserverclient.server.endpoint.endpoint import Endpoint, api
+from requests import Response
+from tableauserverclient.helpers.logging import logger
+from tableauserverclient.models import (
+ DatasourceItem,
+ FavoriteItem,
+ FlowItem,
+ MetricItem,
+ ProjectItem,
+ Resource,
+ TableauItem,
+ UserItem,
+ ViewItem,
+ WorkbookItem,
+)
+from tableauserverclient.server import RequestFactory, RequestOptions
+from typing import Optional
class Favorites(Endpoint):
@property
- def baseurl(self):
- return "{0}/sites/{1}/favorites".format(self.parent_srv.baseurl, self.parent_srv.site_id)
+ def baseurl(self) -> str:
+ return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/favorites"
# Gets all favorites
@api(version="2.5")
- def get(self, user_item, req_options=None):
- logger.info("Querying all favorites for user {0}".format(user_item.name))
- url = "{0}/{1}".format(self.baseurl, user_item.id)
+ def get(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None:
+ logger.info(f"Querying all favorites for user {user_item.name}")
+ url = f"{self.baseurl}/{user_item.id}"
server_response = self.get_request(url, req_options)
-
user_item._favorites = FavoriteItem.from_response(server_response.content, self.parent_srv.namespace)
+ # ---------add to favorites
+
+ @api(version="3.15")
+ def add_favorite(self, user_item: UserItem, content_type: str, item: TableauItem) -> "Response":
+ url = f"{self.baseurl}/{user_item.id}"
+ add_req = RequestFactory.Favorite.add_request(item.id, content_type, item.name)
+ server_response = self.put_request(url, add_req)
+ logger.info(f"Favorited {item.name} for user (ID: {user_item.id})")
+ return server_response
+
@api(version="2.0")
- def add_favorite_workbook(self, user_item, workbook_item):
- url = "{0}/{1}".format(self.baseurl, user_item.id)
+ def add_favorite_workbook(self, user_item: UserItem, workbook_item: WorkbookItem) -> None:
+ url = f"{self.baseurl}/{user_item.id}"
add_req = RequestFactory.Favorite.add_workbook_req(workbook_item.id, workbook_item.name)
server_response = self.put_request(url, add_req)
- logger.info("Favorited {0} for user (ID: {1})".format(workbook_item.name, user_item.id))
+ logger.info(f"Favorited {workbook_item.name} for user (ID: {user_item.id})")
@api(version="2.0")
- def add_favorite_view(self, user_item, view_item):
- url = "{0}/{1}".format(self.baseurl, user_item.id)
+ def add_favorite_view(self, user_item: UserItem, view_item: ViewItem) -> None:
+ url = f"{self.baseurl}/{user_item.id}"
add_req = RequestFactory.Favorite.add_view_req(view_item.id, view_item.name)
server_response = self.put_request(url, add_req)
- logger.info("Favorited {0} for user (ID: {1})".format(view_item.name, user_item.id))
+ logger.info(f"Favorited {view_item.name} for user (ID: {user_item.id})")
@api(version="2.3")
- def add_favorite_datasource(self, user_item, datasource_item):
- url = "{0}/{1}".format(self.baseurl, user_item.id)
+ def add_favorite_datasource(self, user_item: UserItem, datasource_item: DatasourceItem) -> None:
+ url = f"{self.baseurl}/{user_item.id}"
add_req = RequestFactory.Favorite.add_datasource_req(datasource_item.id, datasource_item.name)
server_response = self.put_request(url, add_req)
- logger.info("Favorited {0} for user (ID: {1})".format(datasource_item.name, user_item.id))
+ logger.info(f"Favorited {datasource_item.name} for user (ID: {user_item.id})")
@api(version="3.1")
- def add_favorite_project(self, user_item, project_item):
- url = "{0}/{1}".format(self.baseurl, user_item.id)
+ def add_favorite_project(self, user_item: UserItem, project_item: ProjectItem) -> None:
+ url = f"{self.baseurl}/{user_item.id}"
add_req = RequestFactory.Favorite.add_project_req(project_item.id, project_item.name)
server_response = self.put_request(url, add_req)
- logger.info("Favorited {0} for user (ID: {1})".format(project_item.name, user_item.id))
+ logger.info(f"Favorited {project_item.name} for user (ID: {user_item.id})")
+
+ @api(version="3.3")
+ def add_favorite_flow(self, user_item: UserItem, flow_item: FlowItem) -> None:
+ url = f"{self.baseurl}/{user_item.id}"
+ add_req = RequestFactory.Favorite.add_flow_req(flow_item.id, flow_item.name)
+ server_response = self.put_request(url, add_req)
+ logger.info(f"Favorited {flow_item.name} for user (ID: {user_item.id})")
+
+ @api(version="3.3")
+ def add_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> None:
+ url = f"{self.baseurl}/{user_item.id}"
+ add_req = RequestFactory.Favorite.add_request(metric_item.id, Resource.Metric, metric_item.name)
+ server_response = self.put_request(url, add_req)
+ logger.info(f"Favorited metric {metric_item.name} for user (ID: {user_item.id})")
+
+ # ------- delete from favorites
+ # Response:
+ """
+
+
+
+
+
+ """
+
+ @api(version="3.15")
+ def delete_favorite(self, user_item: UserItem, content_type: Resource, item: TableauItem) -> None:
+ url = f"{self.baseurl}/{user_item.id}/{content_type}/{item.id}"
+ logger.info(f"Removing favorite {content_type}({item.id}) for user (ID: {user_item.id})")
+ self.delete_request(url)
@api(version="2.0")
- def delete_favorite_workbook(self, user_item, workbook_item):
- 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))
+ def delete_favorite_workbook(self, user_item: UserItem, workbook_item: WorkbookItem) -> None:
+ url = f"{self.baseurl}/{user_item.id}/workbooks/{workbook_item.id}"
+ logger.info(f"Removing favorite workbook {workbook_item.id} for user (ID: {user_item.id})")
self.delete_request(url)
@api(version="2.0")
- def delete_favorite_view(self, user_item, view_item):
- 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))
+ def delete_favorite_view(self, user_item: UserItem, view_item: ViewItem) -> None:
+ url = f"{self.baseurl}/{user_item.id}/views/{view_item.id}"
+ logger.info(f"Removing favorite view {view_item.id} for user (ID: {user_item.id})")
self.delete_request(url)
@api(version="2.3")
- def delete_favorite_datasource(self, user_item, datasource_item):
- 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))
+ def delete_favorite_datasource(self, user_item: UserItem, datasource_item: DatasourceItem) -> None:
+ url = f"{self.baseurl}/{user_item.id}/datasources/{datasource_item.id}"
+ logger.info(f"Removing favorite {datasource_item.id} for user (ID: {user_item.id})")
self.delete_request(url)
@api(version="3.1")
- def delete_favorite_project(self, user_item, project_item):
- 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))
+ def delete_favorite_project(self, user_item: UserItem, project_item: ProjectItem) -> None:
+ url = f"{self.baseurl}/{user_item.id}/projects/{project_item.id}"
+ logger.info(f"Removing favorite project {project_item.id} for user (ID: {user_item.id})")
+ self.delete_request(url)
+
+ @api(version="3.3")
+ def delete_favorite_flow(self, user_item: UserItem, flow_item: FlowItem) -> None:
+ url = f"{self.baseurl}/{user_item.id}/flows/{flow_item.id}"
+ logger.info(f"Removing favorite flow {flow_item.id} for user (ID: {user_item.id})")
+ self.delete_request(url)
+
+ @api(version="3.15")
+ def delete_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> None:
+ url = f"{self.baseurl}/{user_item.id}/metrics/{metric_item.id}"
+ logger.info(f"Removing favorite metric {metric_item.id} for user (ID: {user_item.id})")
self.delete_request(url)
diff --git a/tableauserverclient/server/endpoint/fileuploads_endpoint.py b/tableauserverclient/server/endpoint/fileuploads_endpoint.py
index b70cffbaa..c1749af40 100644
--- a/tableauserverclient/server/endpoint/fileuploads_endpoint.py
+++ b/tableauserverclient/server/endpoint/fileuploads_endpoint.py
@@ -1,23 +1,19 @@
-from .exceptions import MissingRequiredFieldError
from .endpoint import Endpoint, api
-from .. import RequestFactory
-from ...models.fileupload_item import FileuploadItem
-import os.path
-import logging
+from tableauserverclient import datetime_helpers as datetime
+from tableauserverclient.helpers.logging import logger
-# For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks
-CHUNK_SIZE = 1024 * 1024 * 5 # 5MB
-
-logger = logging.getLogger("tableau.endpoint.fileuploads")
+from tableauserverclient.config import BYTES_PER_MB, config
+from tableauserverclient.models import FileuploadItem
+from tableauserverclient.server import RequestFactory
class Fileuploads(Endpoint):
def __init__(self, parent_srv):
- super(Fileuploads, self).__init__(parent_srv)
+ super().__init__(parent_srv)
@property
def baseurl(self):
- return "{0}/sites/{1}/fileUploads".format(self.parent_srv.baseurl, self.parent_srv.site_id)
+ return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/fileUploads"
@api(version="2.0")
def initiate(self):
@@ -25,14 +21,14 @@ def initiate(self):
server_response = self.post_request(url, "")
fileupload_item = FileuploadItem.from_response(server_response.content, self.parent_srv.namespace)
upload_id = fileupload_item.upload_session_id
- logger.info("Initiated file upload session (ID: {0})".format(upload_id))
+ logger.info(f"Initiated file upload session (ID: {upload_id})")
return upload_id
@api(version="2.0")
def append(self, upload_id, data, content_type):
- url = "{0}/{1}".format(self.baseurl, upload_id)
+ url = f"{self.baseurl}/{upload_id}"
server_response = self.put_request(url, data, content_type)
- logger.info("Uploading a chunk to session (ID: {0})".format(upload_id))
+ logger.info(f"Uploading a chunk to session (ID: {upload_id})")
return FileuploadItem.from_response(server_response.content, self.parent_srv.namespace)
def _read_chunks(self, file):
@@ -45,7 +41,7 @@ def _read_chunks(self, file):
try:
while True:
- chunked_content = file_content.read(CHUNK_SIZE)
+ chunked_content = file_content.read(config.CHUNK_SIZE_MB * BYTES_PER_MB)
if not chunked_content:
break
yield chunked_content
@@ -56,8 +52,10 @@ def _read_chunks(self, file):
def upload(self, file):
upload_id = self.initiate()
for chunk in self._read_chunks(file):
+ logger.debug(f"{datetime.timestamp()} processing chunk...")
request, content_type = RequestFactory.Fileupload.chunk_req(chunk)
+ logger.debug(f"{datetime.timestamp()} created chunk request")
fileupload_item = self.append(upload_id, request, content_type)
- logger.info("\tPublished {0}MB".format(fileupload_item.file_size))
- logger.info("File upload finished (ID: {0})".format(upload_id))
+ logger.info(f"\t{datetime.timestamp()} Published {fileupload_item.file_size}MB")
+ logger.info(f"File upload finished (ID: {upload_id})")
return upload_id
diff --git a/tableauserverclient/server/endpoint/flow_runs_endpoint.py b/tableauserverclient/server/endpoint/flow_runs_endpoint.py
index 2ae1973d4..2c3bb84bc 100644
--- a/tableauserverclient/server/endpoint/flow_runs_endpoint.py
+++ b/tableauserverclient/server/endpoint/flow_runs_endpoint.py
@@ -1,57 +1,64 @@
-from .endpoint import Endpoint, QuerysetEndpoint, api
-from .exceptions import FlowRunFailedException, FlowRunCancelledException
-from .. import FlowRunItem, PaginationItem
-from ...exponential_backoff import ExponentialBackoffTimer
-
import logging
+from typing import Optional, TYPE_CHECKING, Union
+
+from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api
+from tableauserverclient.server.endpoint.exceptions import FlowRunFailedException, FlowRunCancelledException
+from tableauserverclient.models import FlowRunItem
+from tableauserverclient.exponential_backoff import ExponentialBackoffTimer
+
+from tableauserverclient.helpers.logging import logger
+from tableauserverclient.server.query import QuerySet
-logger = logging.getLogger("tableau.endpoint.flowruns")
+if TYPE_CHECKING:
+ from tableauserverclient.server.server import Server
+ from tableauserverclient.server.request_options import RequestOptions
-class FlowRuns(QuerysetEndpoint):
- def __init__(self, parent_srv):
- super(FlowRuns, self).__init__(parent_srv)
+class FlowRuns(QuerysetEndpoint[FlowRunItem]):
+ def __init__(self, parent_srv: "Server") -> None:
+ super().__init__(parent_srv)
+ return None
@property
- def baseurl(self):
- return "{0}/sites/{1}/flows/runs".format(self.parent_srv.baseurl, self.parent_srv.site_id)
+ def baseurl(self) -> str:
+ return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/flows/runs"
# Get all flows
@api(version="3.10")
- def get(self, req_options=None):
+ # QuerysetEndpoint expects a PaginationItem to be returned, but FlowRuns
+ # does not return a PaginationItem. Suppressing the mypy error because the
+ # changes to the QuerySet class should permit this to function regardless.
+ def get(self, req_options: Optional["RequestOptions"] = None) -> list[FlowRunItem]: # type: ignore[override]
logger.info("Querying all flow runs on site")
url = self.baseurl
server_response = self.get_request(url, req_options)
- pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace)
all_flow_run_items = FlowRunItem.from_response(server_response.content, self.parent_srv.namespace)
- return all_flow_run_items, pagination_item
+ return all_flow_run_items
# Get 1 flow by id
@api(version="3.10")
- 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)
- logger.info("Querying single flow (ID: {0})".format(flow_run_id))
- url = "{0}/{1}".format(self.baseurl, flow_run_id)
+ logger.info(f"Querying single flow (ID: {flow_run_id})")
+ url = f"{self.baseurl}/{flow_run_id}"
server_response = self.get_request(url)
return FlowRunItem.from_response(server_response.content, self.parent_srv.namespace)[0]
-
# Cancel 1 flow run by id
@api(version="3.10")
- def cancel(self, flow_run_id):
+ def cancel(self, flow_run_id: Union[str, FlowRunItem]) -> None:
if not flow_run_id:
error = "Flow ID undefined."
raise ValueError(error)
- id_ = getattr(flow_run_id, 'id', flow_run_id)
- url = "{0}/{1}".format(self.baseurl, id_)
+ id_ = getattr(flow_run_id, "id", flow_run_id)
+ url = f"{self.baseurl}/{id_}"
self.put_request(url)
- logger.info("Deleted single flow (ID: {0})".format(id_))
-
+ logger.info(f"Deleted single flow (ID: {id_})")
@api(version="3.10")
- def wait_for_job(self, flow_run_id, *, 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)
@@ -64,7 +71,7 @@ def wait_for_job(self, flow_run_id, *, timeout=None):
flow_run = self.get_by_id(flow_run_id)
logger.debug(f"\tFlowRun {flow_run_id} progress={flow_run.progress}")
- logger.info("FlowRun {} Completed: Status: {}".format(flow_run_id, flow_run.status))
+ logger.info(f"FlowRun {flow_run_id} Completed: Status: {flow_run.status}")
if flow_run.status == "Success":
return flow_run
@@ -73,4 +80,43 @@ 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)
+
+ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[FlowRunItem]:
+ """
+ Queries the Tableau Server for items using the specified filters. Page
+ size can be specified to limit the number of items returned in a single
+ request. If not specified, the default page size is 100. Page size can
+ be an integer between 1 and 1000.
+
+ No positional arguments are allowed. All filters must be specified as
+ keyword arguments. If you use the equality operator, you can specify it
+ through =. If you want to use a different operator,
+ you can specify it through __=. Field
+ names can either be in snake_case or camelCase.
+
+ This endpoint supports the following fields and operators:
+
+
+ complete_at=...
+ complete_at__gt=...
+ complete_at__gte=...
+ complete_at__lt=...
+ complete_at__lte=...
+ flow_id=...
+ flow_id__in=...
+ progress=...
+ progress__gt=...
+ progress__gte=...
+ progress__lt=...
+ progress__lte=...
+ started_at=...
+ started_at__gt=...
+ started_at__gte=...
+ started_at__lt=...
+ started_at__lte=...
+ user_id=...
+ user_id__in=...
+ """
+
+ return super().filter(*invalid, page_size=page_size, **kwargs)
diff --git a/tableauserverclient/server/endpoint/flow_task_endpoint.py b/tableauserverclient/server/endpoint/flow_task_endpoint.py
new file mode 100644
index 000000000..9e21661e6
--- /dev/null
+++ b/tableauserverclient/server/endpoint/flow_task_endpoint.py
@@ -0,0 +1,29 @@
+import logging
+from typing import TYPE_CHECKING
+
+from tableauserverclient.server.endpoint.endpoint import Endpoint, api
+from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError
+from tableauserverclient.models import TaskItem, PaginationItem
+from tableauserverclient.server import RequestFactory
+
+from tableauserverclient.helpers.logging import logger
+
+if TYPE_CHECKING:
+ from tableauserverclient.server.request_options import RequestOptions
+
+
+class FlowTasks(Endpoint):
+ @property
+ def baseurl(self) -> str:
+ return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks/flows"
+
+ @api(version="3.22")
+ def create(self, flow_item: TaskItem) -> TaskItem:
+ if not flow_item:
+ error = "No flow provided"
+ raise ValueError(error)
+ logger.info("Creating an flow task %s", flow_item)
+ url = self.baseurl
+ create_req = RequestFactory.FlowTask.create_flow_task_req(flow_item)
+ server_response = self.post_request(url, create_req)
+ return server_response.content
diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py
index eb2de4ac9..42c9d4c1e 100644
--- a/tableauserverclient/server/endpoint/flows_endpoint.py
+++ b/tableauserverclient/server/endpoint/flows_endpoint.py
@@ -1,40 +1,90 @@
-from .endpoint import Endpoint, 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
+from email.message import Message
import copy
-import cgi
+import io
+import logging
+import os
from contextlib import closing
+from pathlib import Path
+from typing import Optional, TYPE_CHECKING, Union
+from collections.abc import Iterable
+
+from tableauserverclient.helpers.headers import fix_filename
+
+from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint
+from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api
+from tableauserverclient.server.endpoint.exceptions import InternalServerError, MissingRequiredFieldError
+from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint
+from tableauserverclient.server.endpoint.resource_tagger import _ResourceTagger, TaggingMixin
+from tableauserverclient.models import FlowItem, PaginationItem, ConnectionItem, JobItem
+from tableauserverclient.server import RequestFactory
+from tableauserverclient.filesys_helpers import (
+ to_filename,
+ make_download_path,
+ get_file_type,
+ get_file_object_size,
+)
+from tableauserverclient.server.query import QuerySet
+
+io_types_r = (io.BytesIO, io.BufferedReader)
+io_types_w = (io.BytesIO, io.BufferedWriter)
+
+io_types_r = (io.BytesIO, io.BufferedReader)
+io_types_w = (io.BytesIO, io.BufferedWriter)
# The maximum size of a file that can be published in a single request is 64MB
FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB
ALLOWED_FILE_EXTENSIONS = ["tfl", "tflx"]
-logger = logging.getLogger("tableau.endpoint.flows")
+from tableauserverclient.helpers.logging import logger
+
+if TYPE_CHECKING:
+ from tableauserverclient.models import DQWItem
+ from tableauserverclient.models.permissions_item import PermissionsRule
+ from tableauserverclient.server.request_options import RequestOptions
+ from tableauserverclient.server.endpoint.schedules_endpoint import AddResponse
-class Flows(Endpoint):
+FilePath = Union[str, os.PathLike]
+FileObjectR = Union[io.BufferedReader, io.BytesIO]
+FileObjectW = Union[io.BufferedWriter, io.BytesIO]
+PathOrFileR = Union[FilePath, FileObjectR]
+PathOrFileW = Union[FilePath, FileObjectW]
+
+
+class Flows(QuerysetEndpoint[FlowItem], TaggingMixin[FlowItem]):
def __init__(self, parent_srv):
- super(Flows, self).__init__(parent_srv)
+ super().__init__(parent_srv)
self._resource_tagger = _ResourceTagger(parent_srv)
self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl)
self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "flow")
@property
- def baseurl(self):
- return "{0}/sites/{1}/flows".format(self.parent_srv.baseurl, self.parent_srv.site_id)
+ def baseurl(self) -> str:
+ return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/flows"
# Get all flows
@api(version="3.3")
- def get(self, req_options=None):
+ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[FlowItem], PaginationItem]:
+ """
+ Get all flows on site. Returns a tuple of all flow items and pagination item.
+ This method is paginated, and returns one page of items per call. The
+ request options can be used to specify the page number, page size, as
+ well as sorting and filtering options.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#query_flows_for_site
+
+ Parameters
+ ----------
+ req_options: Optional[RequestOptions]
+ An optional Request Options object that can be used to specify
+ sorting, filtering, and pagination options.
+
+ Returns
+ -------
+ tuple[list[FlowItem], PaginationItem]
+ A tuple of a list of flow items and a pagination item.
+ """
logger.info("Querying all flows on site")
url = self.baseurl
server_response = self.get_request(url, req_options)
@@ -44,18 +94,54 @@ 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:
+ """
+ Get a single flow by id.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#query_flow
+
+ Parameters
+ ----------
+ flow_id: str
+ The id of the flow to retrieve.
+
+ Returns
+ -------
+ FlowItem
+ The flow item that was retrieved.
+ """
if not flow_id:
error = "Flow ID undefined."
raise ValueError(error)
- logger.info("Querying single flow (ID: {0})".format(flow_id))
- url = "{0}/{1}".format(self.baseurl, flow_id)
+ logger.info(f"Querying single flow (ID: {flow_id})")
+ url = f"{self.baseurl}/{flow_id}"
server_response = self.get_request(url)
return FlowItem.from_response(server_response.content, self.parent_srv.namespace)[0]
# Populate flow item's connections
@api(version="3.3")
- def populate_connections(self, flow_item):
+ def populate_connections(self, flow_item: FlowItem) -> None:
+ """
+ Populate the connections for a flow item. This method will make a
+ request to the Tableau Server to get the connections associated with
+ the flow item and populate the connections property of the flow item.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#query_flow_connections
+
+ Parameters
+ ----------
+ flow_item: FlowItem
+ The flow item to populate connections for.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ MissingRequiredFieldError
+ If the flow item does not have an ID.
+ """
if not flow_item.id:
error = "Flow item missing ID. Flow must be retrieved from server first."
raise MissingRequiredFieldError(error)
@@ -64,48 +150,118 @@ def connections_fetcher():
return self._get_flow_connections(flow_item)
flow_item._set_connections(connections_fetcher)
- logger.info("Populated connections for flow (ID: {0})".format(flow_item.id))
+ logger.info(f"Populated connections for flow (ID: {flow_item.id})")
- def _get_flow_connections(self, flow_item, req_options=None):
- url = "{0}/{1}/connections".format(self.baseurl, flow_item.id)
+ def _get_flow_connections(self, flow_item, req_options: Optional["RequestOptions"] = None) -> list[ConnectionItem]:
+ url = f"{self.baseurl}/{flow_item.id}/connections"
server_response = self.get_request(url, req_options)
connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)
return connections
# Delete 1 flow by id
@api(version="3.3")
- def delete(self, flow_id):
+ def delete(self, flow_id: str) -> None:
+ """
+ Delete a single flow by id.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#delete_flow
+
+ Parameters
+ ----------
+ flow_id: str
+ The id of the flow to delete.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ ValueError
+ If the flow_id is not defined.
+ """
if not flow_id:
error = "Flow ID undefined."
raise ValueError(error)
- url = "{0}/{1}".format(self.baseurl, flow_id)
+ url = f"{self.baseurl}/{flow_id}"
self.delete_request(url)
- logger.info("Deleted single flow (ID: {0})".format(flow_id))
+ logger.info(f"Deleted single flow (ID: {flow_id})")
# Download 1 flow by id
@api(version="3.3")
- def download(self, flow_id, filepath=None):
+ def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> PathOrFileW:
+ """
+ Download a single flow by id. The flow will be downloaded to the
+ specified file path. If no file path is specified, the flow will be
+ downloaded to the current working directory.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#download_flow
+
+ Parameters
+ ----------
+ flow_id: str
+ The id of the flow to download.
+
+ filepath: Optional[PathOrFileW]
+ The file path to download the flow to. This can be a file path or
+ a file object. If a file object is passed, the flow will be written
+ to the file object. If a file path is passed, the flow will be
+ written to the file path. If no file path is specified, the flow
+ will be downloaded to the current working directory.
+
+ Returns
+ -------
+ PathOrFileW
+ The file path or file object that the flow was downloaded to.
+
+ Raises
+ ------
+ ValueError
+ If the flow_id is not defined.
+ """
if not flow_id:
error = "Flow ID undefined."
raise ValueError(error)
- url = "{0}/{1}/content".format(self.baseurl, flow_id)
+ url = f"{self.baseurl}/{flow_id}/content"
with closing(self.get_request(url, parameters={"stream": True})) as server_response:
- _, 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:
+ m = Message()
+ m["Content-Disposition"] = server_response.headers["Content-Disposition"]
+ params = m.get_filename(failobj="")
+ if isinstance(filepath, io_types_w):
for chunk in server_response.iter_content(1024): # 1KB
- f.write(chunk)
-
- logger.info("Downloaded flow to {0} (ID: {1})".format(download_path, flow_id))
- return os.path.abspath(download_path)
+ filepath.write(chunk)
+ return_path = filepath
+ else:
+ params = fix_filename(params)
+ filename = to_filename(os.path.basename(params))
+ download_path = make_download_path(filepath, filename)
+ with open(download_path, "wb") as f:
+ for chunk in server_response.iter_content(1024): # 1KB
+ f.write(chunk)
+ return_path = os.path.abspath(download_path)
+
+ logger.info(f"Downloaded flow to {return_path} (ID: {flow_id})")
+ return return_path
# Update flow
@api(version="3.3")
- def update(self, flow_item):
+ def update(self, flow_item: FlowItem) -> FlowItem:
+ """
+ Updates the flow owner, project, description, and/or tags.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#update_flow
+
+ Parameters
+ ----------
+ flow_item: FlowItem
+ The flow item to update.
+
+ Returns
+ -------
+ FlowItem
+ The updated flow item.
+ """
if not flow_item.id:
error = "Flow item missing ID. Flow must be retrieved from server first."
raise MissingRequiredFieldError(error)
@@ -113,28 +269,62 @@ def update(self, flow_item):
self._resource_tagger.update_tags(self.baseurl, flow_item)
# Update the flow itself
- url = "{0}/{1}".format(self.baseurl, flow_item.id)
+ url = f"{self.baseurl}/{flow_item.id}"
update_req = RequestFactory.Flow.update_req(flow_item)
server_response = self.put_request(url, update_req)
- logger.info("Updated flow item (ID: {0})".format(flow_item.id))
+ logger.info(f"Updated flow item (ID: {flow_item.id})")
updated_flow = copy.copy(flow_item)
return updated_flow._parse_common_elements(server_response.content, self.parent_srv.namespace)
# Update flow connections
@api(version="3.3")
- def update_connection(self, flow_item, connection_item):
- url = "{0}/{1}/connections/{2}".format(self.baseurl, flow_item.id, connection_item.id)
+ def update_connection(self, flow_item: FlowItem, connection_item: ConnectionItem) -> ConnectionItem:
+ """
+ Update a connection item for a flow item. This method will update the
+ connection details for the connection item associated with the flow.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#update_flow_connection
+
+ Parameters
+ ----------
+ flow_item: FlowItem
+ The flow item that the connection is associated with.
+
+ connection_item: ConnectionItem
+ The connection item to update.
+
+ Returns
+ -------
+ ConnectionItem
+ The updated connection item.
+ """
+ url = f"{self.baseurl}/{flow_item.id}/connections/{connection_item.id}"
update_req = RequestFactory.Connection.update_req(connection_item)
server_response = self.put_request(url, update_req)
connection = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0]
- logger.info("Updated flow item (ID: {0} & connection item {1}".format(flow_item.id, connection_item.id))
+ logger.info(f"Updated flow item (ID: {flow_item.id} & connection item {connection_item.id}")
return connection
@api(version="3.3")
- def refresh(self, flow_item):
- url = "{0}/{1}/run".format(self.baseurl, flow_item.id)
+ def refresh(self, flow_item: FlowItem) -> JobItem:
+ """
+ Runs the flow to refresh the data.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#run_flow_now
+
+ Parameters
+ ----------
+ flow_item: FlowItem
+ The flow item to refresh.
+
+ Returns
+ -------
+ JobItem
+ The job item that was created to refresh the flow.
+ """
+ url = f"{self.baseurl}/{flow_item.id}/run"
empty_req = RequestFactory.Empty.empty_req()
server_response = self.post_request(url, empty_req)
new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0]
@@ -142,39 +332,104 @@ def refresh(self, flow_item):
# Publish flow
@api(version="3.3")
- def publish(self, flow_item, file_path, mode, connections=None):
- if not os.path.isfile(file_path):
- error = "File path does not lead to an existing file."
- raise IOError(error)
+ def publish(
+ self, flow_item: FlowItem, file: PathOrFileR, mode: str, connections: Optional[list[ConnectionItem]] = None
+ ) -> FlowItem:
+ """
+ Publishes a flow to the Tableau Server.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#publish_flow
+
+ Parameters
+ ----------
+ flow_item: FlowItem
+ The flow item to publish. This item must have a project_id and name
+ defined.
+
+ file: PathOrFileR
+ The file path or file object to publish. This can be a .tfl or .tflx
+
+ mode: str
+ The publish mode. This can be "Overwrite" or "CreatNew". If the
+ mode is "Overwrite", the flow will be overwritten if it already
+ exists. If the mode is "CreateNew", a new flow will be created with
+ the same name as the flow item.
+
+ connections: Optional[list[ConnectionItem]]
+ A list of connection items to publish with the flow. If the flow
+ contains connections, they must be included in this list.
+
+ Returns
+ -------
+ FlowItem
+ The flow item that was published.
+ """
if not mode or not hasattr(self.parent_srv.PublishMode, mode):
error = "Invalid mode defined."
raise ValueError(error)
- filename = os.path.basename(file_path)
- file_extension = os.path.splitext(filename)[1][1:]
+ if isinstance(file, (str, os.PathLike)):
+ if not os.path.isfile(file):
+ error = "File path does not lead to an existing file."
+ raise OSError(error)
+
+ filename = os.path.basename(file)
+ file_extension = os.path.splitext(filename)[1][1:]
+ file_size = os.path.getsize(file)
+
+ # If name is not defined, grab the name from the file to publish
+ if not flow_item.name:
+ flow_item.name = os.path.splitext(filename)[0]
+ if file_extension not in ALLOWED_FILE_EXTENSIONS:
+ error = "Only {} files can be published as flows.".format(", ".join(ALLOWED_FILE_EXTENSIONS))
+ raise ValueError(error)
+
+ elif isinstance(file, io_types_r):
+ if not flow_item.name:
+ error = "Flow item must have a name when passing a file object"
+ raise ValueError(error)
+
+ file_type = get_file_type(file)
+ if file_type == "zip":
+ file_extension = "tflx"
+ elif file_type == "xml":
+ file_extension = "tfl"
+ else:
+ error = f"Unsupported file type {file_type}!"
+ raise ValueError(error)
+
+ # Generate filename for file object.
+ # This is needed when publishing the flow in a single request
+ filename = f"{flow_item.name}.{file_extension}"
+ file_size = get_file_object_size(file)
- # If name is not defined, grab the name from the file to publish
- if not flow_item.name:
- flow_item.name = os.path.splitext(filename)[0]
- if file_extension not in ALLOWED_FILE_EXTENSIONS:
- error = "Only {} files can be published as flows.".format(", ".join(ALLOWED_FILE_EXTENSIONS))
- raise ValueError(error)
+ else:
+ raise TypeError("file should be a filepath or file object.")
# Construct the url with the defined mode
- url = "{0}?flowType={1}".format(self.baseurl, file_extension)
+ url = f"{self.baseurl}?flowType={file_extension}"
if mode == self.parent_srv.PublishMode.Overwrite or mode == self.parent_srv.PublishMode.Append:
- url += "&{0}=true".format(mode.lower())
+ url += f"&{mode.lower()}=true"
# Determine if chunking is required (64MB is the limit for single upload method)
- if os.path.getsize(file_path) >= FILESIZE_LIMIT:
- logger.info("Publishing {0} to server with chunking method (flow over 64MB)".format(filename))
- upload_session_id = self.parent_srv.fileuploads.upload(file_path)
- url = "{0}&uploadSessionId={1}".format(url, upload_session_id)
+ if file_size >= FILESIZE_LIMIT:
+ logger.info(f"Publishing {filename} to server with chunking method (flow over 64MB)")
+ upload_session_id = self.parent_srv.fileuploads.upload(file)
+ url = f"{url}&uploadSessionId={upload_session_id}"
xml_request, content_type = RequestFactory.Flow.publish_req_chunked(flow_item, connections)
else:
- logger.info("Publishing {0} to server".format(filename))
- with open(file_path, "rb") as f:
- file_contents = f.read()
+ logger.info(f"Publishing {filename} to server")
+
+ if isinstance(file, (str, Path)):
+ with open(file, "rb") as f:
+ file_contents = f.read()
+
+ elif isinstance(file, io_types_r):
+ file_contents = file.read()
+
+ else:
+ raise TypeError("file should be a filepath or file object.")
+
xml_request, content_type = RequestFactory.Flow.publish_req(flow_item, filename, file_contents, connections)
# Send the publishing request to server
@@ -186,48 +441,209 @@ def publish(self, flow_item, file_path, mode, connections=None):
raise err
else:
new_flow = FlowItem.from_response(server_response.content, self.parent_srv.namespace)[0]
- logger.info("Published {0} (ID: {1})".format(filename, new_flow.id))
+ logger.info(f"Published {filename} (ID: {new_flow.id})")
return new_flow
- 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:
+ """
+ Populate the permissions for a flow item. This method will make a
+ request to the Tableau Server to get the permissions associated with
+ the flow item and populate the permissions property of the flow item.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#query_flow_permissions
+
+ Parameters
+ ----------
+ item: FlowItem
+ The flow item to populate permissions for.
+
+ Returns
+ -------
+ None
+ """
self._permissions.populate(item)
@api(version="3.3")
- def update_permission(self, item, permission_item):
- import warnings
-
- warnings.warn(
- "Server.flows.update_permission is deprecated, " "please use Server.flows.update_permissions instead.",
- DeprecationWarning,
- )
+ def update_permissions(self, item: FlowItem, permission_item: Iterable["PermissionsRule"]) -> None:
+ """
+ Update the permissions for a flow item. This method will update the
+ permissions for the flow item. The permissions must be a list of
+ permissions rules. Will overwrite all existing permissions.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_permissions_for_content
+
+ Parameters
+ ----------
+ item: FlowItem
+ The flow item to update permissions for.
+
+ permission_item: Iterable[PermissionsRule]
+ The permissions rules to update.
+
+ Returns
+ -------
+ None
+ """
self._permissions.update(item, permission_item)
@api(version="3.3")
- def update_permissions(self, item, permission_item):
- self._permissions.update(item, permission_item)
+ def delete_permission(self, item: FlowItem, capability_item: "PermissionsRule") -> None:
+ """
+ Delete a permission for a flow item. This method will delete only the
+ specified permission for the flow item.
- @api(version="3.3")
- def delete_permission(self, item, capability_item):
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#delete_flow_permission
+
+ Parameters
+ ----------
+ item: FlowItem
+ The flow item to delete the permission from.
+
+ capability_item: PermissionsRule
+ The permission to delete.
+
+ Returns
+ -------
+ None
+ """
self._permissions.delete(item, capability_item)
@api(version="3.5")
- def populate_dqw(self, item):
+ def populate_dqw(self, item: FlowItem) -> None:
+ """
+ Get information about Data Quality Warnings for a flow item.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_dqws
+
+ Parameters
+ ----------
+ item: FlowItem
+ The flow item to populate data quality warnings for.
+
+ Returns
+ -------
+ None
+ """
self._data_quality_warnings.populate(item)
@api(version="3.5")
- def update_dqw(self, item, warning):
+ def update_dqw(self, item: FlowItem, warning: "DQWItem") -> None:
+ """
+ Update the warning type, status, and message of a data quality warning
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#update_dqw
+
+ Parameters
+ ----------
+ item: FlowItem
+ The flow item to update data quality warnings for.
+
+ warning: DQWItem
+ The data quality warning to update.
+
+ Returns
+ -------
+ None
+ """
return self._data_quality_warnings.update(item, warning)
@api(version="3.5")
- def add_dqw(self, item, warning):
+ def add_dqw(self, item: FlowItem, warning: "DQWItem") -> None:
+ """
+ Add a data quality warning to a flow.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#add_dqw
+
+ Parameters
+ ----------
+ item: FlowItem
+ The flow item to add data quality warnings to.
+
+ warning: DQWItem
+ The data quality warning to add.
+
+ Returns
+ -------
+ None
+ """
return self._data_quality_warnings.add(item, warning)
@api(version="3.5")
- def delete_dqw(self, item):
+ def delete_dqw(self, item: FlowItem) -> None:
+ """
+ Delete all data quality warnings for a flow.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#delete_dqws
+
+ Parameters
+ ----------
+ item: FlowItem
+ The flow item to delete data quality warnings from.
+
+ Returns
+ -------
+ None
+ """
self._data_quality_warnings.clear(item)
+
+ # a convenience method
+ @api(version="3.3")
+ def schedule_flow_run(
+ self, schedule_id: str, item: FlowItem
+ ) -> list["AddResponse"]: # actually should return a task
+ """
+ Schedule a flow to run on an existing schedule.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#add_flow_task_to_schedule
+
+ Parameters
+ ----------
+ schedule_id: str
+ The id of the schedule to add the flow to.
+
+ item: FlowItem
+ The flow item to add to the schedule.
+
+ Returns
+ -------
+ list[AddResponse]
+ The response from the server.
+ """
+ return self.parent_srv.schedules.add_to_schedule(schedule_id, flow=item)
+
+ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[FlowItem]:
+ """
+ Queries the Tableau Server for items using the specified filters. Page
+ size can be specified to limit the number of items returned in a single
+ request. If not specified, the default page size is 100. Page size can
+ be an integer between 1 and 1000.
+
+ No positional arguments are allowed. All filters must be specified as
+ keyword arguments. If you use the equality operator, you can specify it
+ through =. If you want to use a different operator,
+ you can specify it through __=. Field
+ names can either be in snake_case or camelCase.
+
+ This endpoint supports the following fields and operators:
+
+
+ created_at=...
+ created_at__gt=...
+ created_at__gte=...
+ created_at__lt=...
+ created_at__lte=...
+ name=...
+ name__in=...
+ owner_name=...
+ project_id=...
+ project_name=...
+ project_name__in=...
+ updated=...
+ updated__gt=...
+ updated__gte=...
+ updated__lt=...
+ updated__lte=...
+ """
+
+ return super().filter(*invalid, page_size=page_size, **kwargs)
diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py
index b771e56d8..4e9af4076 100644
--- a/tableauserverclient/server/endpoint/groups_endpoint.py
+++ b/tableauserverclient/server/endpoint/groups_endpoint.py
@@ -1,21 +1,73 @@
-from .endpoint import Endpoint, 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 tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api
+from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError
+from tableauserverclient.server import RequestFactory
+from tableauserverclient.models import GroupItem, UserItem, PaginationItem, JobItem
+from tableauserverclient.server.pager import Pager
+
+from tableauserverclient.helpers.logging import logger
+
+from typing import Literal, Optional, TYPE_CHECKING, Union, overload
+from collections.abc import Iterable
+
+from tableauserverclient.server.query import QuerySet
+
+if TYPE_CHECKING:
+ from tableauserverclient.server.request_options import RequestOptions
+
+class Groups(QuerysetEndpoint[GroupItem]):
+ """
+ Groups endpoint for creating, reading, updating, and deleting groups on
+ Tableau Server.
+ """
-class Groups(Endpoint):
@property
- def baseurl(self):
- return "{0}/sites/{1}/groups".format(self.parent_srv.baseurl, self.parent_srv.site_id)
+ def baseurl(self) -> str:
+ return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/groups"
- # 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]:
+ """
+ Returns information about the groups on the site.
+
+ To get information about the users in a group, you must first populate
+ the GroupItem with user information using the groups.populate_users
+ method.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#query_groups
+
+ Parameters
+ ----------
+ req_options : Optional[RequestOptions]
+ (Optional) You can pass the method a request object that contains
+ additional parameters to filter the request. For example, if you
+ were searching for a specific group, you could specify the name of
+ the group or the group id.
+
+ Returns
+ -------
+ tuple[list[GroupItem], PaginationItem]
+
+ Examples
+ --------
+ >>> # import tableauserverclient as TSC
+ >>> # tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD')
+ >>> # server = TSC.Server('https://SERVERURL')
+
+ >>> with server.auth.sign_in(tableau_auth):
+
+ >>> # get the groups on the server
+ >>> all_groups, pagination_item = server.groups.get()
+
+ >>> # print the names of the first 100 groups
+ >>> for group in all_groups :
+ >>> print(group.name, group.id)
+
+
+
+ """
logger.info("Querying all groups on site")
url = self.baseurl
server_response = self.get_request(url, req_options)
@@ -23,9 +75,44 @@ def get(self, req_options=None):
all_group_items = GroupItem.from_response(server_response.content, self.parent_srv.namespace)
return all_group_items, pagination_item
- # Gets all users in a given group
@api(version="2.0")
- def populate_users(self, group_item, req_options=None):
+ def populate_users(self, group_item: GroupItem, req_options: Optional["RequestOptions"] = None) -> None:
+ """
+ Populates the group_item with the list of users.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#get_users_in_group
+
+ Parameters
+ ----------
+ group_item : GroupItem
+ The group item to populate with user information.
+
+ req_options : Optional[RequestOptions]
+ (Optional) You can pass the method a request object that contains
+ page size and page number.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ MissingRequiredFieldError
+ If the group item does not have an ID, the method raises an error.
+
+ Examples
+ --------
+ >>> # Get the group item from the server
+ >>> groups, pagination_item = server.groups.get()
+ >>> group = groups[1]
+
+ >>> # Populate the group with user information
+ >>> server.groups.populate_users(group)
+ >>> for user in group.users:
+ >>> print(user.name)
+
+
+ """
if not group_item.id:
error = "Group item missing ID. Group must be retrieved from server first."
raise MissingRequiredFieldError(error)
@@ -40,37 +127,88 @@ def user_pager():
group_item._set_users(user_pager)
- def _get_users_for_group(self, group_item, req_options=None):
- url = "{0}/{1}/users".format(self.baseurl, group_item.id)
+ def _get_users_for_group(
+ self, group_item: GroupItem, req_options: Optional["RequestOptions"] = None
+ ) -> tuple[list[UserItem], PaginationItem]:
+ url = f"{self.baseurl}/{group_item.id}/users"
server_response = self.get_request(url, req_options)
user_item = UserItem.from_response(server_response.content, self.parent_srv.namespace)
pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace)
- logger.info("Populated users for group (ID: {0})".format(group_item.id))
+ logger.info(f"Populated users for group (ID: {group_item.id})")
return user_item, pagination_item
- # Deletes 1 group by id
@api(version="2.0")
- def delete(self, group_id):
+ def delete(self, group_id: str) -> None:
+ """
+ Deletes the group on the site.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#delete_group
+
+ Parameters
+ ----------
+ group_id: str
+ The id for the group you want to remove from the server
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ ValueError
+ If the group_id is not provided, the method raises an error.
+
+ Examples
+ --------
+ >>> groups, pagination_item = server.groups.get()
+ >>> group = groups[1]
+ >>> server.groups.delete(group.id)
+
+ """
if not group_id:
error = "Group ID undefined."
raise ValueError(error)
- url = "{0}/{1}".format(self.baseurl, group_id)
+ url = f"{self.baseurl}/{group_id}"
self.delete_request(url)
- logger.info("Deleted single group (ID: {0})".format(group_id))
+ logger.info(f"Deleted single group (ID: {group_id})")
+
+ @overload
+ def update(self, group_item: GroupItem, as_job: Literal[False]) -> GroupItem: ...
+
+ @overload
+ def update(self, group_item: GroupItem, as_job: Literal[True]) -> JobItem: ...
@api(version="2.0")
- def update(self, group_item, default_site_role=None, as_job=False):
- # (1/8/2021): Deprecated starting v0.15
- if default_site_role is not None:
- import warnings
-
- warnings.simplefilter("always", DeprecationWarning)
- warnings.warn(
- 'Groups.update(...default_site_role=""...) is deprecated, '
- "please set the minimum_site_role field of GroupItem",
- DeprecationWarning,
- )
- group_item.minimum_site_role = default_site_role
+ def update(self, group_item, as_job=False):
+ """
+ Updates a group on the site.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#update_group
+
+ Parameters
+ ----------
+ group_item : GroupItem
+ The group item to update.
+
+ as_job : bool
+ (Optional) If this value is set to True, the update operation will
+ be asynchronous and return a JobItem. This is only supported for
+ Active Directory groups. By default, this value is set to False.
+
+ Returns
+ -------
+ Union[GroupItem, JobItem]
+
+ Raises
+ ------
+ MissingRequiredFieldError
+ If the group_item does not have an ID, the method raises an error.
+
+ ValueError
+ If the group_item is a local group and as_job is set to True, the
+ method raises an error.
+ """
+ url = f"{self.baseurl}/{group_item.id}"
if not group_item.id:
error = "Group item missing ID."
@@ -78,27 +216,84 @@ def update(self, group_item, default_site_role=None, as_job=False):
if as_job and (group_item.domain_name is None or group_item.domain_name == "local"):
error = "Local groups cannot be updated asynchronously."
raise ValueError(error)
+ elif as_job:
+ url = "?".join([url, "asJob=True"])
- url = "{0}/{1}".format(self.baseurl, group_item.id)
- update_req = RequestFactory.Group.update_req(group_item, None)
+ update_req = RequestFactory.Group.update_req(group_item)
server_response = self.put_request(url, update_req)
- logger.info("Updated group item (ID: {0})".format(group_item.id))
+ logger.info(f"Updated group item (ID: {group_item.id})")
if as_job:
return JobItem.from_response(server_response.content, self.parent_srv.namespace)[0]
else:
return GroupItem.from_response(server_response.content, self.parent_srv.namespace)[0]
- # Create a 'local' Tableau group
@api(version="2.0")
- def create(self, group_item):
+ def create(self, group_item: GroupItem) -> GroupItem:
+ """
+ Create a 'local' Tableau group
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#create_group
+
+ Parameters
+ ----------
+ group_item : GroupItem
+ The group item to create. The group_item specifies the group to add.
+ You first create a new instance of a GroupItem and pass that to this
+ method.
+
+ Returns
+ -------
+ GroupItem
+
+ Examples
+ --------
+ >>> new_group = TSC.GroupItem('new_group')
+ >>> new_group.minimum_site_role = TSC.UserItem.Role.ExplorerCanPublish
+ >>> new_group = server.groups.create(new_group)
+
+ """
url = self.baseurl
create_req = RequestFactory.Group.create_local_req(group_item)
server_response = self.post_request(url, create_req)
return GroupItem.from_response(server_response.content, self.parent_srv.namespace)[0]
- # Create a group based on Active Directory
+ @overload
+ def create_AD_group(self, group_item: GroupItem, asJob: Literal[False]) -> GroupItem: ...
+
+ @overload
+ def create_AD_group(self, group_item: GroupItem, asJob: Literal[True]) -> JobItem: ...
+
@api(version="2.0")
def create_AD_group(self, group_item, asJob=False):
+ """
+ Create a group based on Active Directory.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#create_group
+
+ Parameters
+ ----------
+ group_item : GroupItem
+ The group item to create. The group_item specifies the group to add.
+ You first create a new instance of a GroupItem and pass that to this
+ method.
+
+ asJob : bool
+ (Optional) If this value is set to True, the create operation will
+ be asynchronous and return a JobItem. This is only supported for
+ Active Directory groups. By default, this value is set to False.
+
+ Returns
+ -------
+ Union[GroupItem, JobItem]
+
+ Examples
+ --------
+ >>> new_ad_group = TSC.GroupItem('new_ad_group')
+ >>> new_ad_group.domain_name = 'example.com'
+ >>> new_ad_group.minimum_site_role = TSC.UserItem.Role.ExplorerCanPublish
+ >>> new_ad_group.license_mode = TSC.GroupItem.LicenseMode.onSync
+ >>> new_ad_group = server.groups.create_AD_group(new_ad_group)
+ """
asJobparameter = "?asJob=true" if asJob else ""
url = self.baseurl + asJobparameter
create_req = RequestFactory.Group.create_ad_req(group_item)
@@ -108,31 +303,218 @@ def create_AD_group(self, group_item, asJob=False):
else:
return GroupItem.from_response(server_response.content, self.parent_srv.namespace)[0]
- # Removes 1 user from 1 group
@api(version="2.0")
- def remove_user(self, group_item, user_id):
+ def remove_user(self, group_item: GroupItem, user_id: str) -> None:
+ """
+ Removes 1 user from 1 group
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#remove_user_to_group
+
+ Parameters
+ ----------
+ group_item : GroupItem
+ The group item from which to remove the user.
+
+ user_id : str
+ The ID of the user to remove from the group.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ MissingRequiredFieldError
+ If the group_item does not have an ID, the method raises an error.
+
+ ValueError
+ If the user_id is not provided, the method raises an error.
+
+ Examples
+ --------
+ >>> group = server.groups.get_by_id('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p')
+ >>> server.groups.populate_users(group)
+ >>> server.groups.remove_user(group, group.users[0].id)
+ """
if not group_item.id:
error = "Group item missing ID."
raise MissingRequiredFieldError(error)
if not user_id:
error = "User ID undefined."
raise ValueError(error)
- url = "{0}/{1}/users/{2}".format(self.baseurl, group_item.id, user_id)
+ url = f"{self.baseurl}/{group_item.id}/users/{user_id}"
self.delete_request(url)
- logger.info("Removed user (id: {0}) from group (ID: {1})".format(user_id, group_item.id))
+ logger.info(f"Removed user (id: {user_id}) from group (ID: {group_item.id})")
+
+ @api(version="3.21")
+ def remove_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> None:
+ """
+ Removes multiple users from 1 group. This makes a single API call to
+ remove the provided users.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#remove_users_to_group
+
+ Parameters
+ ----------
+ group_item : GroupItem
+ The group item from which to remove the user.
+
+ users : Iterable[Union[str, UserItem]]
+ The IDs or UserItems with IDs of the users to remove from the group.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ ValueError
+ If the group_item is not a GroupItem or str, the method raises an error.
+
+ Examples
+ --------
+ >>> group = server.groups.get_by_id('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p')
+ >>> server.groups.populate_users(group)
+ >>> users = [u for u in group.users if u.domain_name == 'example.com']
+ >>> server.groups.remove_users(group, users)
+
+ """
+ group_id = group_item.id if hasattr(group_item, "id") else group_item
+ if not isinstance(group_id, str):
+ raise ValueError(f"Invalid group provided: {group_item}")
+
+ url = f"{self.baseurl}/{group_id}/users/remove"
+ add_req = RequestFactory.Group.remove_users_req(users)
+ _ = self.put_request(url, add_req)
+ logger.info(f"Removed users to group (ID: {group_item.id})")
+ return None
- # 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:
+ """
+ Adds 1 user to 1 group
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#add_user_to_group
+
+ Parameters
+ ----------
+ group_item : GroupItem
+ The group item to which to add the user.
+
+ user_id : str
+ The ID of the user to add to the group.
+
+ Returns
+ -------
+ UserItem
+ UserItem for the user that was added to the group.
+
+ Raises
+ ------
+ MissingRequiredFieldError
+ If the group_item does not have an ID, the method raises an error.
+
+ ValueError
+ If the user_id is not provided, the method raises an error.
+
+ Examples
+ --------
+ >>> group = server.groups.get_by_id('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p')
+ >>> server.groups.add_user(group, '1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p')
+ """
if not group_item.id:
error = "Group item missing ID."
raise MissingRequiredFieldError(error)
if not user_id:
error = "User ID undefined."
raise ValueError(error)
- url = "{0}/{1}/users".format(self.baseurl, group_item.id)
+ url = f"{self.baseurl}/{group_item.id}/users"
add_req = RequestFactory.Group.add_user_req(user_id)
server_response = self.post_request(url, add_req)
user = UserItem.from_response(server_response.content, self.parent_srv.namespace).pop()
- logger.info("Added user (id: {0}) to group (ID: {1})".format(user_id, group_item.id))
+ logger.info(f"Added user (id: {user_id}) to group (ID: {group_item.id})")
return user
+
+ @api(version="3.21")
+ def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> list[UserItem]:
+ """
+ Adds 1 or more user to 1 group
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#add_user_to_group
+
+ Parameters
+ ----------
+ group_item : GroupItem
+ The group item to which to add the user.
+
+ user_id : Iterable[Union[str, UserItem]]
+ User IDs or UserItems with IDs to add to the group.
+
+ Returns
+ -------
+ list[UserItem]
+ UserItem for the user that was added to the group.
+
+ Raises
+ ------
+ MissingRequiredFieldError
+ If the group_item does not have an ID, the method raises an error.
+
+ ValueError
+ If the user_id is not provided, the method raises an error.
+
+ Examples
+ --------
+ >>> group = server.groups.get_by_id('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p')
+ >>> added_users = server.groups.add_users(group, '1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p')
+ """
+ """Adds multiple users to 1 group"""
+ group_id = group_item.id if hasattr(group_item, "id") else group_item
+ if not isinstance(group_id, str):
+ raise ValueError(f"Invalid group provided: {group_item}")
+
+ url = f"{self.baseurl}/{group_id}/users"
+ add_req = RequestFactory.Group.add_users_req(users)
+ server_response = self.post_request(url, add_req)
+ users = UserItem.from_response(server_response.content, self.parent_srv.namespace)
+ logger.info(f"Added users to group (ID: {group_item.id})")
+ return users
+
+ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[GroupItem]:
+ """
+ Queries the Tableau Server for items using the specified filters. Page
+ size can be specified to limit the number of items returned in a single
+ request. If not specified, the default page size is 100. Page size can
+ be an integer between 1 and 1000.
+
+ No positional arguments are allowed. All filters must be specified as
+ keyword arguments. If you use the equality operator, you can specify it
+ through =. If you want to use a different operator,
+ you can specify it through __=. Field
+ names can either be in snake_case or camelCase.
+
+ This endpoint supports the following fields and operators:
+
+
+ domain_name=...
+ domain_name__in=...
+ domain_nickname=...
+ domain_nickname__in=...
+ is_external_user_enabled=...
+ is_local=...
+ luid=...
+ luid__in=...
+ minimum_site_role=...
+ minimum_site_role__in=...
+ name__cieq=...
+ name=...
+ name__in=...
+ name__like=...
+ user_count=...
+ user_count__gt=...
+ user_count__gte=...
+ user_count__lt=...
+ user_count__lte=...
+ """
+
+ return super().filter(*invalid, page_size=page_size, **kwargs)
diff --git a/tableauserverclient/server/endpoint/groupsets_endpoint.py b/tableauserverclient/server/endpoint/groupsets_endpoint.py
new file mode 100644
index 000000000..8c0ef64f3
--- /dev/null
+++ b/tableauserverclient/server/endpoint/groupsets_endpoint.py
@@ -0,0 +1,127 @@
+from typing import Literal, Optional, TYPE_CHECKING, Union
+
+from tableauserverclient.helpers.logging import logger
+from tableauserverclient.models.group_item import GroupItem
+from tableauserverclient.models.groupset_item import GroupSetItem
+from tableauserverclient.models.pagination_item import PaginationItem
+from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint
+from tableauserverclient.server.query import QuerySet
+from tableauserverclient.server.request_options import RequestOptions
+from tableauserverclient.server.request_factory import RequestFactory
+from tableauserverclient.server.endpoint.endpoint import api
+
+if TYPE_CHECKING:
+ from tableauserverclient.server import Server
+
+
+class GroupSets(QuerysetEndpoint[GroupSetItem]):
+ def __init__(self, parent_srv: "Server") -> None:
+ super().__init__(parent_srv)
+
+ @property
+ def baseurl(self) -> str:
+ return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/groupsets"
+
+ @api(version="3.22")
+ def get(
+ self,
+ req_options: Optional[RequestOptions] = None,
+ result_level: Optional[Literal["members", "local"]] = None,
+ ) -> tuple[list[GroupSetItem], PaginationItem]:
+ logger.info("Querying all group sets on site")
+ url = self.baseurl
+ if result_level:
+ url += f"?resultlevel={result_level}"
+ server_response = self.get_request(url, req_options)
+ pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace)
+ all_group_set_items = GroupSetItem.from_response(server_response.content, self.parent_srv.namespace)
+ return all_group_set_items, pagination_item
+
+ @api(version="3.22")
+ def get_by_id(self, groupset_id: str) -> GroupSetItem:
+ logger.info(f"Querying group set (ID: {groupset_id})")
+ url = f"{self.baseurl}/{groupset_id}"
+ server_response = self.get_request(url)
+ all_group_set_items = GroupSetItem.from_response(server_response.content, self.parent_srv.namespace)
+ return all_group_set_items[0]
+
+ @api(version="3.22")
+ def create(self, groupset_item: GroupSetItem) -> GroupSetItem:
+ logger.info(f"Creating group set (name: {groupset_item.name})")
+ url = self.baseurl
+ request = RequestFactory.GroupSet.create_request(groupset_item)
+ server_response = self.post_request(url, request)
+ created_groupset = GroupSetItem.from_response(server_response.content, self.parent_srv.namespace)
+ return created_groupset[0]
+
+ @api(version="3.22")
+ def add_group(self, groupset_item: GroupSetItem, group: Union[GroupItem, str]) -> None:
+ group_id = group.id if isinstance(group, GroupItem) else group
+ logger.info(f"Adding group (ID: {group_id}) to group set (ID: {groupset_item.id})")
+ url = f"{self.baseurl}/{groupset_item.id}/groups/{group_id}"
+ _ = self.put_request(url)
+ return None
+
+ @api(version="3.22")
+ def remove_group(self, groupset_item: GroupSetItem, group: Union[GroupItem, str]) -> None:
+ group_id = group.id if isinstance(group, GroupItem) else group
+ logger.info(f"Removing group (ID: {group_id}) from group set (ID: {groupset_item.id})")
+ url = f"{self.baseurl}/{groupset_item.id}/groups/{group_id}"
+ _ = self.delete_request(url)
+ return None
+
+ @api(version="3.22")
+ def delete(self, groupset: Union[GroupSetItem, str]) -> None:
+ groupset_id = groupset.id if isinstance(groupset, GroupSetItem) else groupset
+ logger.info(f"Deleting group set (ID: {groupset_id})")
+ url = f"{self.baseurl}/{groupset_id}"
+ _ = self.delete_request(url)
+ return None
+
+ @api(version="3.22")
+ def update(self, groupset: GroupSetItem) -> GroupSetItem:
+ logger.info(f"Updating group set (ID: {groupset.id})")
+ url = f"{self.baseurl}/{groupset.id}"
+ request = RequestFactory.GroupSet.update_request(groupset)
+ server_response = self.put_request(url, request)
+ updated_groupset = GroupSetItem.from_response(server_response.content, self.parent_srv.namespace)
+ return updated_groupset[0]
+
+ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[GroupSetItem]:
+ """
+ Queries the Tableau Server for items using the specified filters. Page
+ size can be specified to limit the number of items returned in a single
+ request. If not specified, the default page size is 100. Page size can
+ be an integer between 1 and 1000.
+
+ No positional arguments are allowed. All filters must be specified as
+ keyword arguments. If you use the equality operator, you can specify it
+ through =. If you want to use a different operator,
+ you can specify it through __=. Field
+ names can either be in snake_case or camelCase.
+
+ This endpoint supports the following fields and operators:
+
+
+ domain_name=...
+ domain_name__in=...
+ domain_nickname=...
+ domain_nickname__in=...
+ is_external_user_enabled=...
+ is_local=...
+ luid=...
+ luid__in=...
+ minimum_site_role=...
+ minimum_site_role__in=...
+ name__cieq=...
+ name=...
+ name__in=...
+ name__like=...
+ user_count=...
+ user_count__gt=...
+ user_count__gte=...
+ user_count__lt=...
+ user_count__lte=...
+ """
+
+ return super().filter(*invalid, page_size=page_size, **kwargs)
diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py
index 4cdbcc5be..48e91bd74 100644
--- a/tableauserverclient/server/endpoint/jobs_endpoint.py
+++ b/tableauserverclient/server/endpoint/jobs_endpoint.py
@@ -1,20 +1,64 @@
-from .endpoint import Endpoint, api
-from .exceptions import JobCancelledException, JobFailedException
-from .. import JobItem, BackgroundJobItem, PaginationItem
-from ..request_options import RequestOptionsBase
-from ...exponential_backoff import ExponentialBackoffTimer
-
import logging
+from typing_extensions import Self, overload
+
+
+from tableauserverclient.models import JobItem, BackgroundJobItem, PaginationItem
+from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api
+from tableauserverclient.server.endpoint.exceptions import JobCancelledException, JobFailedException
+from tableauserverclient.server.query import QuerySet
+from tableauserverclient.server.request_options import RequestOptionsBase
+from tableauserverclient.exponential_backoff import ExponentialBackoffTimer
+
+from tableauserverclient.helpers.logging import logger
+
+from typing import Optional, Union
-logger = logging.getLogger("tableau.endpoint.jobs")
-class Jobs(Endpoint):
+class Jobs(QuerysetEndpoint[BackgroundJobItem]):
@property
def baseurl(self):
- return "{0}/sites/{1}/jobs".format(self.parent_srv.baseurl, self.parent_srv.site_id)
+ return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/jobs"
+
+ @overload # type: ignore[override]
+ def get(self: Self, job_id: str, req_options: Optional[RequestOptionsBase] = None) -> JobItem: # type: ignore[override]
+ ...
+
+ @overload # type: ignore[override]
+ def get(self: Self, job_id: RequestOptionsBase, req_options: None) -> tuple[list[BackgroundJobItem], PaginationItem]: # type: ignore[override]
+ ...
+
+ @overload # type: ignore[override]
+ def get(self: Self, job_id: None, req_options: Optional[RequestOptionsBase]) -> tuple[list[BackgroundJobItem], PaginationItem]: # type: ignore[override]
+ ...
@api(version="2.6")
def get(self, job_id=None, req_options=None):
+ """
+ Retrieve jobs for the site. Endpoint is paginated and will return a
+ list of jobs and pagination information. If a job_id is provided, the
+ method will return information about that specific job. Specifying a
+ job_id is deprecated and will be removed in a future version.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#query_jobs
+
+ Parameters
+ ----------
+ job_id : str or RequestOptionsBase
+ The ID of the job to retrieve. If None, the method will return all
+ jobs for the site. If a RequestOptions object is provided, the
+ method will use the options to filter the jobs.
+
+ req_options : RequestOptionsBase
+ The request options to filter the jobs. If None, the method will
+ return all jobs for the site.
+
+ Returns
+ -------
+ tuple[list[BackgroundJobItem], PaginationItem] or JobItem
+ If a job_id is provided, the method will return a JobItem. If no
+ job_id is provided, the method will return a tuple containing a
+ list of BackgroundJobItems and a PaginationItem.
+ """
# Backwards Compatibility fix until we rev the major version
if job_id is not None and isinstance(job_id, str):
import warnings
@@ -24,28 +68,112 @@ def get(self, job_id=None, req_options=None):
if isinstance(job_id, RequestOptionsBase):
req_options = job_id
- self.parent_srv.assert_at_least_version("3.1")
+ self.parent_srv.assert_at_least_version("3.1", "Jobs.get_by_id(job_id)")
server_response = self.get_request(self.baseurl, req_options)
pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace)
jobs = BackgroundJobItem.from_response(server_response.content, self.parent_srv.namespace)
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]):
+ """
+ Cancels a job specified by job ID. To get a list of job IDs for jobs that are currently queued or in-progress, use the Query Jobs method.
+
+ The following jobs can be canceled using the Cancel Job method:
+
+ Full extract refresh
+ Incremental extract refresh
+ Subscription
+ Flow Run
+ Data Acceleration (Data acceleration is not available in Tableau Server 2022.1 (API 3.16) and later. See View Acceleration(Link opens in a new window).)
+ Bridge full extract refresh
+ Bridge incremental extract refresh
+ Queue upgrade Thumbnail (Job that puts the upgrade thumbnail job on the queue)
+ Upgrade Thumbnail
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#cancel_job
+
+ Parameters
+ ----------
+ job_id : str or JobItem
+ The ID of the job to cancel. If a JobItem is provided, the method
+ will use the ID from the JobItem.
+
+ Returns
+ -------
+ None
+ """
+ if isinstance(job_id, JobItem):
+ job_id = job_id.id
+ assert isinstance(job_id, str)
+ url = f"{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:
+ """
+ Returns status information about an asynchronous process that is tracked
+ using a job. This method can be used to query jobs that are used to do
+ the following:
+
+ Import users from Active Directory (the result of a call to Create Group).
+ Synchronize an existing Tableau Server group with Active Directory (the result of a call to Update Group).
+ Run extract refresh tasks (the result of a call to Run Extract Refresh Task).
+ Publish a workbook asynchronously (the result of a call to Publish Workbook).
+ Run workbook or view subscriptions (the result of a call to Create Subscription or Update Subscription)
+ Run a flow task (the result of a call to Run Flow Task)
+ Status of Tableau Server site deletion (the result of a call to asynchronous Delete Site(Link opens in a new window) beginning API 3.18)
+ Note: To query a site deletion job, the server administrator must be first signed into the default site (contentUrl=" ").
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#query_job
+
+ Parameters
+ ----------
+ job_id : str
+ The ID of the job to retrieve.
+
+ Returns
+ -------
+ JobItem
+ The JobItem object that contains information about the requested job.
+ """
logger.info("Query for information about job " + job_id)
- url = "{0}/{1}".format(self.baseurl, job_id)
+ url = f"{self.baseurl}/{job_id}"
server_response = self.get_request(url)
new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0]
return new_job
- @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:
+ """
+ Waits for a job to complete. The method will poll the server for the job
+ status until the job is completed. If the job is successful, the method
+ will return the JobItem. If the job fails, the method will raise a
+ JobFailedException. If the job is cancelled, the method will raise a
+ JobCancelledException.
+
+ Parameters
+ ----------
+ job_id : str or JobItem
+ The ID of the job to wait for. If a JobItem is provided, the method
+ will use the ID from the JobItem.
+
+ timeout : float | None
+ The maximum amount of time to wait for the job to complete. If None,
+ the method will wait indefinitely.
+
+ Returns
+ -------
+ JobItem
+ The JobItem object that contains information about the completed job.
+
+ Raises
+ ------
+ JobFailedException
+ If the job failed to complete.
+
+ JobCancelledException
+ If the job was cancelled.
+ """
if isinstance(job_id, JobItem):
job_id = job_id.id
assert isinstance(job_id, str)
@@ -58,9 +186,9 @@ def wait_for_job(self, job_id, *, timeout=None):
job = self.get_by_id(job_id)
logger.debug(f"\tJob {job_id} progress={job.progress}")
- logger.info("Job {} Completed: Finish Code: {} - Notes:{}".format(job_id, job.finish_code, job.notes))
+ logger.info(f"Job {job_id} Completed: Finish Code: {job.finish_code} - Notes:{job.notes}")
- if job.finish_code == JobItem.FinishCode.Success:
+ if job.finish_code in [JobItem.FinishCode.Success, JobItem.FinishCode.Completed]:
return job
elif job.finish_code == JobItem.FinishCode.Failed:
raise JobFailedException(job)
@@ -68,3 +196,57 @@ def wait_for_job(self, job_id, *, timeout=None):
raise JobCancelledException(job)
else:
raise AssertionError("Unexpected finish_code in job", job)
+
+ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[BackgroundJobItem]:
+ """
+ Queries the Tableau Server for items using the specified filters. Page
+ size can be specified to limit the number of items returned in a single
+ request. If not specified, the default page size is 100. Page size can
+ be an integer between 1 and 1000.
+
+ No positional arguments are allowed. All filters must be specified as
+ keyword arguments. If you use the equality operator, you can specify it
+ through =. If you want to use a different operator,
+ you can specify it through __=. Field
+ names can either be in snake_case or camelCase.
+
+ This endpoint supports the following fields and operators:
+
+
+ args__has=...
+ completed_at=...
+ completed_at__gt=...
+ completed_at__gte=...
+ completed_at__lt=...
+ completed_at__lte=...
+ created_at=...
+ created_at__gt=...
+ created_at__gte=...
+ created_at__lt=...
+ created_at__lte=...
+ job_type=...
+ job_type__in=...
+ notes__has=...
+ priority=...
+ priority__gt=...
+ priority__gte=...
+ priority__lt=...
+ priority__lte=...
+ progress=...
+ progress__gt=...
+ progress__gte=...
+ progress__lt=...
+ progress__lte=...
+ started_at=...
+ started_at__gt=...
+ started_at__gte=...
+ started_at__lt=...
+ started_at__lte=...
+ status=...
+ subtitle=...
+ subtitle__has=...
+ title=...
+ title__has=...
+ """
+
+ return super().filter(*invalid, page_size=page_size, **kwargs)
diff --git a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py
new file mode 100644
index 000000000..ede4d38e3
--- /dev/null
+++ b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py
@@ -0,0 +1,45 @@
+from typing import Optional, Union
+
+from tableauserverclient.helpers.logging import logger
+from tableauserverclient.models.linked_tasks_item import LinkedTaskItem, LinkedTaskJobItem
+from tableauserverclient.models.pagination_item import PaginationItem
+from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api
+from tableauserverclient.server.request_factory import RequestFactory
+from tableauserverclient.server.request_options import RequestOptions
+
+
+class LinkedTasks(QuerysetEndpoint[LinkedTaskItem]):
+ def __init__(self, parent_srv):
+ super().__init__(parent_srv)
+ self._parent_srv = parent_srv
+
+ @property
+ def baseurl(self) -> str:
+ return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks/linked"
+
+ @api(version="3.15")
+ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[LinkedTaskItem], PaginationItem]:
+ logger.info("Querying all linked tasks on site")
+ url = self.baseurl
+ server_response = self.get_request(url, req_options)
+ pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace)
+ all_group_items = LinkedTaskItem.from_response(server_response.content, self.parent_srv.namespace)
+ return all_group_items, pagination_item
+
+ @api(version="3.15")
+ def get_by_id(self, linked_task: Union[LinkedTaskItem, str]) -> LinkedTaskItem:
+ task_id = getattr(linked_task, "id", linked_task)
+ logger.info("Querying all linked tasks on site")
+ url = f"{self.baseurl}/{task_id}"
+ server_response = self.get_request(url)
+ all_group_items = LinkedTaskItem.from_response(server_response.content, self.parent_srv.namespace)
+ return all_group_items[0]
+
+ @api(version="3.15")
+ def run_now(self, linked_task: Union[LinkedTaskItem, str]) -> LinkedTaskJobItem:
+ task_id = getattr(linked_task, "id", linked_task)
+ logger.info(f"Running linked task {task_id} now")
+ url = f"{self.baseurl}/{task_id}/runNow"
+ empty_req = RequestFactory.Empty.empty_req()
+ server_response = self.post_request(url, empty_req)
+ return LinkedTaskJobItem.from_response(server_response.content, self.parent_srv.namespace)
diff --git a/tableauserverclient/server/endpoint/metadata_endpoint.py b/tableauserverclient/server/endpoint/metadata_endpoint.py
index adc7b2666..e5dbcbcf8 100644
--- a/tableauserverclient/server/endpoint/metadata_endpoint.py
+++ b/tableauserverclient/server/endpoint/metadata_endpoint.py
@@ -1,10 +1,10 @@
-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")
+from tableauserverclient.helpers.logging import logger
def is_valid_paged_query(parsed_query):
@@ -42,19 +42,19 @@ def extract(obj, arr, key):
def get_page_info(result):
- next_page = extract_values(result, "hasNextPage").pop()
- cursor = extract_values(result, "endCursor").pop()
- return next_page, cursor
+ next_page = extract_values(result, "hasNextPage")
+ cursor = extract_values(result, "endCursor")
+ return next_page.pop() if next_page else None, cursor.pop() if cursor else None
class Metadata(Endpoint):
@property
def baseurl(self):
- return "{0}/api/metadata/graphql".format(self.parent_srv.server_address)
+ return f"{self.parent_srv.server_address}/api/metadata/graphql"
@property
def control_baseurl(self):
- return "{0}/api/metadata/v1/control".format(self.parent_srv.server_address)
+ return f"{self.parent_srv.server_address}/api/metadata/v1/control"
@api("3.5")
def query(self, query, variables=None, abort_on_error=False, parameters=None):
diff --git a/tableauserverclient/server/endpoint/metrics_endpoint.py b/tableauserverclient/server/endpoint/metrics_endpoint.py
new file mode 100644
index 000000000..3fea1f5b6
--- /dev/null
+++ b/tableauserverclient/server/endpoint/metrics_endpoint.py
@@ -0,0 +1,77 @@
+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 tableauserverclient.server import RequestFactory
+from tableauserverclient.models import MetricItem, PaginationItem
+
+import logging
+
+from typing import Optional, TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from ..request_options import RequestOptions
+ from ...server import Server
+
+
+from tableauserverclient.helpers.logging import logger
+
+
+class Metrics(QuerysetEndpoint[MetricItem]):
+ def __init__(self, parent_srv: "Server") -> None:
+ super().__init__(parent_srv)
+ self._resource_tagger = _ResourceTagger(parent_srv)
+ self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl)
+ self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "metric")
+
+ @property
+ def baseurl(self) -> str:
+ return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/metrics"
+
+ # Get all metrics
+ @api(version="3.9")
+ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[MetricItem], PaginationItem]:
+ 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(f"Querying single metric (ID: {metric_id})")
+ url = f"{self.baseurl}/{metric_id}"
+ server_response = self.get_request(url)
+ return MetricItem.from_response(server_response.content, self.parent_srv.namespace)[0]
+
+ # 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 = f"{self.baseurl}/{metric_id}"
+ self.delete_request(url)
+ logger.info(f"Deleted single metric (ID: {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 = f"{self.baseurl}/{metric_item.id}"
+ update_req = RequestFactory.Metric.update_req(metric_item)
+ server_response = self.put_request(url, update_req)
+ logger.info(f"Updated metric item (ID: {metric_item.id})")
+ 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..10d420ff7 100644
--- a/tableauserverclient/server/endpoint/permissions_endpoint.py
+++ b/tableauserverclient/server/endpoint/permissions_endpoint.py
@@ -1,41 +1,50 @@
import logging
-from .. import RequestFactory, PermissionsRule
+from tableauserverclient.server import RequestFactory
+from tableauserverclient.models import TableauItem, PermissionsRule
from .endpoint import Endpoint
from .exceptions import MissingRequiredFieldError
+from typing import Callable, TYPE_CHECKING, Optional, Union
-logger = logging.getLogger(__name__)
+from tableauserverclient.helpers.logging import logger
+
+if TYPE_CHECKING:
+ from ..server import Server
+ from ..request_options import RequestOptions
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):
- super(_PermissionsEndpoint, self).__init__(parent_srv)
+ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> None:
+ super().__init__(parent_srv)
# owner_baseurl is the baseurl of the parent. The MUST be a lambda
# since we don't know the full site URL until we sign in. If
# populated without, we will get a sign-in error
self.owner_baseurl = owner_baseurl
- def update(self, resource, permissions):
- url = "{0}/{1}/permissions".format(self.owner_baseurl(), resource.id)
+ def __str__(self):
+ return f""
+
+ def update(self, resource: TableauItem, permissions: list[PermissionsRule]) -> list[PermissionsRule]:
+ url = f"{self.owner_baseurl()}/{resource.id}/permissions"
update_req = RequestFactory.Permission.add_req(permissions)
response = self.put_request(url, update_req)
permissions = PermissionsRule.from_response(response.content, self.parent_srv.namespace)
- logger.info("Updated permissions for resource {0}".format(resource.id))
+ logger.info(f"Updated permissions for resource {resource.id}: {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
@@ -45,7 +54,7 @@ def delete(self, resource, rules):
for rule in rules:
for capability, mode in rule.capabilities.items():
"/permissions/groups/group-id/capability-name/capability-mode"
- url = "{0}/{1}/permissions/{2}/{3}/{4}/{5}".format(
+ url = "{}/{}/permissions/{}/{}/{}/{}".format(
self.owner_baseurl(),
resource.id,
rule.grantee.tag_name + "s",
@@ -54,15 +63,13 @@ def delete(self, resource, rules):
mode,
)
- logger.debug("Removing {0} permission for capabilty {1}".format(mode, capability))
+ logger.debug(f"Removing {mode} permission for capability {capability}")
self.delete_request(url)
- logger.info(
- "Deleted permission for {0} {1} item {2}".format(rule.grantee.tag_name, rule.grantee.id, resource.id)
- )
+ logger.info(f"Deleted permission for {rule.grantee.tag_name} {rule.grantee.id} item {resource.id}")
- def populate(self, item):
+ 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)
@@ -71,11 +78,12 @@ def permission_fetcher():
return self._get_permissions(item)
item._set_permissions(permission_fetcher)
- logger.info("Populated permissions for item (ID: {0})".format(item.id))
+ logger.info(f"Populated permissions for item (ID: {item.id})")
- def _get_permissions(self, item, req_options=None):
- url = "{0}/{1}/permissions".format(self.owner_baseurl(), item.id)
+ def _get_permissions(self, item: TableauItem, req_options: Optional["RequestOptions"] = None):
+ url = f"{self.owner_baseurl()}/{item.id}/permissions"
server_response = self.get_request(url, req_options)
permissions = PermissionsRule.from_response(server_response.content, self.parent_srv.namespace)
+ logger.info(f"Permissions for resource {item.id}: {permissions}")
return permissions
diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py
index 72286e570..68eb573cc 100644
--- a/tableauserverclient/server/endpoint/projects_endpoint.py
+++ b/tableauserverclient/server/endpoint/projects_endpoint.py
@@ -1,28 +1,59 @@
-from .endpoint import api, Endpoint
-from .exceptions import MissingRequiredFieldError
-from .permissions_endpoint import _PermissionsEndpoint
-from .default_permissions_endpoint import _DefaultPermissionsEndpoint
+import logging
-from .. import RequestFactory, ProjectItem, PaginationItem, Permission
+from tableauserverclient.server.endpoint.default_permissions_endpoint import _DefaultPermissionsEndpoint
+from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api, XML_CONTENT_TYPE
+from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError
+from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint
+from tableauserverclient.server import RequestFactory, RequestOptions
+from tableauserverclient.models.permissions_item import PermissionsRule
+from tableauserverclient.models import ProjectItem, PaginationItem, Resource
-import logging
+from typing import Optional, TYPE_CHECKING
+
+from tableauserverclient.server.query import QuerySet
-logger = logging.getLogger("tableau.endpoint.projects")
+if TYPE_CHECKING:
+ from tableauserverclient.server.server import Server
+ from tableauserverclient.server.request_options import RequestOptions
+from tableauserverclient.helpers.logging import logger
-class Projects(Endpoint):
- def __init__(self, parent_srv):
- super(Projects, self).__init__(parent_srv)
+
+class Projects(QuerysetEndpoint[ProjectItem]):
+ """
+ The project methods are based upon the endpoints for projects in the REST
+ API and operate on the ProjectItem class.
+ """
+
+ def __init__(self, parent_srv: "Server") -> None:
+ super().__init__(parent_srv)
self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl)
self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl)
@property
- def baseurl(self):
- return "{0}/sites/{1}/projects".format(self.parent_srv.baseurl, self.parent_srv.site_id)
+ def baseurl(self) -> str:
+ return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/projects"
@api(version="2.0")
- def get(self, req_options=None):
+ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[ProjectItem], PaginationItem]:
+ """
+ Retrieves all projects on the site. The endpoint is paginated and can
+ be filtered using the req_options parameter.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_projects.htm#query_projects
+
+ Parameters
+ ----------
+ req_options : RequestOptions | None, default None
+ The request options to filter the projects. The default is None.
+
+ Returns
+ -------
+ tuple[list[ProjectItem], PaginationItem]
+ Returns a tuple containing a list of ProjectItem objects and a
+ PaginationItem object.
+ """
logger.info("Querying all projects on site")
url = self.baseurl
server_response = self.get_request(url, req_options)
@@ -31,91 +62,789 @@ 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:
+ """
+ Deletes a single project on the site.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_projects.htm#delete_project
+
+ Parameters
+ ----------
+ project_id : str
+ The unique identifier for the project.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ ValueError
+ If the project ID is not defined, an error is raised.
+ """
if not project_id:
error = "Project ID undefined."
raise ValueError(error)
- url = "{0}/{1}".format(self.baseurl, project_id)
+ url = f"{self.baseurl}/{project_id}"
self.delete_request(url)
- logger.info("Deleted single project (ID: {0})".format(project_id))
+ logger.info(f"Deleted single project (ID: {project_id})")
@api(version="2.0")
- def update(self, project_item):
+ def update(self, project_item: ProjectItem, samples: bool = False) -> ProjectItem:
+ """
+ Modify the project settings.
+
+ You can use this method to update the project name, the project
+ description, or the project permissions. To specify the site, create a
+ TableauAuth instance using the content URL for the site (site_id), and
+ sign in to that site.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_projects.htm#update_project
+
+ Parameters
+ ----------
+ project_item : ProjectItem
+ The project item object must include the project ID. The values in
+ the project item override the current project settings.
+
+ samples : bool
+ Set to True to include sample workbooks and data sources in the
+ project. The default is False.
+
+ Returns
+ -------
+ ProjectItem
+ Returns the updated project item.
+
+ Raises
+ ------
+ MissingRequiredFieldError
+ If the project item is missing the ID, an error is raised.
+ """
if not project_item.id:
error = "Project item missing ID."
raise MissingRequiredFieldError(error)
- url = "{0}/{1}".format(self.baseurl, project_item.id)
+ params = {"params": {RequestOptions.Field.PublishSamples: samples}}
+ url = f"{self.baseurl}/{project_item.id}"
update_req = RequestFactory.Project.update_req(project_item)
- server_response = self.put_request(url, update_req)
- logger.info("Updated project item (ID: {0})".format(project_item.id))
+ server_response = self.put_request(url, update_req, XML_CONTENT_TYPE, params)
+ logger.info(f"Updated project item (ID: {project_item.id})")
updated_project = ProjectItem.from_response(server_response.content, self.parent_srv.namespace)[0]
return updated_project
@api(version="2.0")
- def create(self, project_item):
+ def create(self, project_item: ProjectItem, samples: bool = False) -> ProjectItem:
+ """
+ Creates a project on the specified site.
+
+ To create a project, you first create a new instance of a ProjectItem
+ and pass it to the create method. To specify the site to create the new
+ project, create a TableauAuth instance using the content URL for the
+ site (site_id), and sign in to that site.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_projects.htm#create_project
+
+ Parameters
+ ----------
+ project_item : ProjectItem
+ Specifies the properties for the project. The project_item is the
+ request package. To create the request package, create a new
+ instance of ProjectItem.
+
+ samples : bool
+ Set to True to include sample workbooks and data sources in the
+ project. The default is False.
+
+ Returns
+ -------
+ ProjectItem
+ Returns the new project item.
+ """
+ params = {"params": {RequestOptions.Field.PublishSamples: samples}}
url = self.baseurl
+ if project_item._samples:
+ url = f"{self.baseurl}?publishSamples={project_item._samples}"
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))
+ logger.info(f"Created new project (ID: {new_project.id})")
return new_project
@api(version="2.0")
- def populate_permissions(self, item):
+ def populate_permissions(self, item: ProjectItem) -> None:
+ """
+ Queries the project permissions, parses and stores the returned the permissions.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_project_permissions
+
+ Parameters
+ ----------
+ item : ProjectItem
+ The project item to populate with permissions.
+
+ Returns
+ -------
+ None
+ """
self._permissions.populate(item)
@api(version="2.0")
- def update_permission(self, item, rules):
- import warnings
-
- warnings.warn(
- "Server.projects.update_permission is deprecated, "
- "please use Server.projects.update_permissions instead.",
- DeprecationWarning,
- )
- return self._permissions.update(item, rules)
+ def update_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]:
+ """
+ Updates the permissions for the specified project item. The rules
+ provided are expected to be a complete list of the permissions for the
+ project.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_permissions_for_content
+
+ Parameters
+ ----------
+ item : ProjectItem
+ The project item to update permissions for.
+
+ rules : list[PermissionsRule]
+ The list of permissions rules to update the project with.
+
+ Returns
+ -------
+ list[PermissionsRule]
+ Returns the updated list of permissions rules.
+ """
- @api(version="2.0")
- def update_permissions(self, item, rules):
return self._permissions.update(item, rules)
@api(version="2.0")
- def delete_permission(self, item, rules):
+ def delete_permission(self, item: ProjectItem, rules: list[PermissionsRule]) -> None:
+ """
+ Deletes the specified permissions from the project item.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_project_permission
+
+ Parameters
+ ----------
+ item : ProjectItem
+ The project item to delete permissions from.
+
+ rules : list[PermissionsRule]
+ The list of permissions rules to delete from the project.
+
+ Returns
+ -------
+ None
+ """
self._permissions.delete(item, rules)
@api(version="2.1")
- def populate_workbook_default_permissions(self, item):
- self._default_permissions.populate_default_permissions(item, Permission.Resource.Workbook)
+ def populate_workbook_default_permissions(self, item: ProjectItem) -> None:
+ """
+ Queries the default workbook permissions, parses and stores the
+ returned the permissions.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions
+
+ Parameters
+ ----------
+ item : ProjectItem
+ The project item to populate with default workbook permissions.
+
+ Returns
+ -------
+ None
+ """
+ self._default_permissions.populate_default_permissions(item, Resource.Workbook)
@api(version="2.1")
- def populate_datasource_default_permissions(self, item):
- self._default_permissions.populate_default_permissions(item, Permission.Resource.Datasource)
+ def populate_datasource_default_permissions(self, item: ProjectItem) -> None:
+ """
+ Queries the default datasource permissions, parses and stores the
+ returned the permissions.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions
+
+ Parameters
+ ----------
+ item : ProjectItem
+ The project item to populate with default datasource permissions.
+
+ Returns
+ -------
+ None
+ """
+ self._default_permissions.populate_default_permissions(item, Resource.Datasource)
+
+ @api(version="3.2")
+ def populate_metric_default_permissions(self, item: ProjectItem) -> None:
+ """
+ Queries the default metric permissions, parses and stores the
+ returned the permissions.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions
+
+ Parameters
+ ----------
+ item : ProjectItem
+ The project item to populate with default metric permissions.
+
+ Returns
+ -------
+ None
+ """
+ self._default_permissions.populate_default_permissions(item, Resource.Metric)
@api(version="3.4")
- def populate_flow_default_permissions(self, item):
- self._default_permissions.populate_default_permissions(item, Permission.Resource.Flow)
+ def populate_datarole_default_permissions(self, item: ProjectItem) -> None:
+ """
+ Queries the default datarole permissions, parses and stores the
+ returned the permissions.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions
+
+ Parameters
+ ----------
+ item : ProjectItem
+ The project item to populate with default datarole permissions.
+
+ Returns
+ -------
+ None
+ """
+ self._default_permissions.populate_default_permissions(item, Resource.Datarole)
+
+ @api(version="3.4")
+ def populate_flow_default_permissions(self, item: ProjectItem) -> None:
+ """
+ Queries the default flow permissions, parses and stores the
+ returned the permissions.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions
+
+ Parameters
+ ----------
+ item : ProjectItem
+ The project item to populate with default flow permissions.
+
+ Returns
+ -------
+ None
+ """
+ self._default_permissions.populate_default_permissions(item, Resource.Flow)
+
+ @api(version="3.4")
+ def populate_lens_default_permissions(self, item: ProjectItem) -> None:
+ """
+ Queries the default lens permissions, parses and stores the
+ returned the permissions.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions
+
+ Parameters
+ ----------
+ item : ProjectItem
+ The project item to populate with default lens permissions.
+
+ Returns
+ -------
+ None
+ """
+ self._default_permissions.populate_default_permissions(item, Resource.Lens)
+
+ @api(version="3.23")
+ def populate_virtualconnection_default_permissions(self, item: ProjectItem) -> None:
+ """
+ Queries the default virtualconnections permissions, parses and stores
+ the returned the permissions.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions
+
+ Parameters
+ ----------
+ item : ProjectItem
+ The project item to populate with default virtual connection
+ permissions.
+
+ Returns
+ -------
+ None
+ """
+ self._default_permissions.populate_default_permissions(item, Resource.VirtualConnection)
+
+ @api(version="3.23")
+ def populate_database_default_permissions(self, item: ProjectItem) -> None:
+ """
+ Queries the default database permissions, parses and stores the
+ returned the permissions.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions
+
+ Parameters
+ ----------
+ item : ProjectItem
+ The project item to populate with default database permissions.
+
+ Returns
+ -------
+ None
+ """
+ self._default_permissions.populate_default_permissions(item, Resource.Database)
+
+ @api(version="3.23")
+ def populate_table_default_permissions(self, item: ProjectItem) -> None:
+ """
+ Queries the default table permissions, parses and stores the
+ returned the permissions.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions
+
+ Parameters
+ ----------
+ item : ProjectItem
+ The project item to populate with default table permissions.
+
+ Returns
+ -------
+ None
+ """
+ self._default_permissions.populate_default_permissions(item, Resource.Table)
@api(version="2.1")
- def update_workbook_default_permissions(self, item, rules):
- return self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Workbook)
+ def update_workbook_default_permissions(
+ self, item: ProjectItem, rules: list[PermissionsRule]
+ ) -> list[PermissionsRule]:
+ """
+ Add or updates the default workbook permissions for the specified.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content
+
+ Parameters
+ ----------
+ item : ProjectItem
+ The project item to update default workbook permissions for.
+
+ rules : list[PermissionsRule]
+ The list of permissions rules to update the project with.
+
+ Returns
+ -------
+ list[PermissionsRule]
+ Returns the updated list of permissions rules.
+ """
+ return self._default_permissions.update_default_permissions(item, rules, Resource.Workbook)
@api(version="2.1")
- def update_datasource_default_permissions(self, item, rules):
- return self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Datasource)
+ def update_datasource_default_permissions(
+ self, item: ProjectItem, rules: list[PermissionsRule]
+ ) -> list[PermissionsRule]:
+ """
+ Add or updates the default datasource permissions for the specified.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content
+
+ Parameters
+ ----------
+ item : ProjectItem
+ The project item to update default datasource permissions for.
+
+ rules : list[PermissionsRule]
+ The list of permissions rules to update the project with.
+
+ Returns
+ -------
+ list[PermissionsRule]
+ Returns the updated list of permissions rules.
+ """
+ return self._default_permissions.update_default_permissions(item, rules, Resource.Datasource)
+
+ @api(version="3.2")
+ def update_metric_default_permissions(
+ self, item: ProjectItem, rules: list[PermissionsRule]
+ ) -> list[PermissionsRule]:
+ """
+ Add or updates the default metric permissions for the specified.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content
+
+ Parameters
+ ----------
+ item : ProjectItem
+ The project item to update default metric permissions for.
+
+ rules : list[PermissionsRule]
+ The list of permissions rules to update the project with.
+
+ Returns
+ -------
+ list[PermissionsRule]
+ Returns the updated list of permissions rules.
+ """
+ return self._default_permissions.update_default_permissions(item, rules, Resource.Metric)
@api(version="3.4")
- def update_flow_default_permissions(self, item, rules):
- return self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Flow)
+ def update_datarole_default_permissions(
+ self, item: ProjectItem, rules: list[PermissionsRule]
+ ) -> list[PermissionsRule]:
+ """
+ Add or updates the default datarole permissions for the specified.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content
+
+ Parameters
+ ----------
+ item : ProjectItem
+ The project item to update default datarole permissions for.
+
+ rules : list[PermissionsRule]
+ The list of permissions rules to update the project with.
+
+ Returns
+ -------
+ list[PermissionsRule]
+ Returns the updated list of permissions rules.
+ """
+ return self._default_permissions.update_default_permissions(item, rules, Resource.Datarole)
+
+ @api(version="3.4")
+ def update_flow_default_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]:
+ """
+ Add or updates the default flow permissions for the specified.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content
+
+ Parameters
+ ----------
+ item : ProjectItem
+ The project item to update default flow permissions for.
+
+ rules : list[PermissionsRule]
+ The list of permissions rules to update the project with.
+
+ Returns
+ -------
+ list[PermissionsRule]
+ Returns the updated list of permissions rules.
+ """
+ return self._default_permissions.update_default_permissions(item, rules, Resource.Flow)
+
+ @api(version="3.4")
+ def update_lens_default_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]:
+ """
+ Add or updates the default lens permissions for the specified.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content
+
+ Parameters
+ ----------
+ item : ProjectItem
+ The project item to update default lens permissions for.
+
+ rules : list[PermissionsRule]
+ The list of permissions rules to update the project with.
+
+ Returns
+ -------
+ list[PermissionsRule]
+ Returns the updated list of permissions rules.
+ """
+ return self._default_permissions.update_default_permissions(item, rules, Resource.Lens)
+
+ @api(version="3.23")
+ def update_virtualconnection_default_permissions(
+ self, item: ProjectItem, rules: list[PermissionsRule]
+ ) -> list[PermissionsRule]:
+ """
+ Add or updates the default virtualconnection permissions for the specified.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content
+
+ Parameters
+ ----------
+ item : ProjectItem
+ The project item to update default virtualconnection permissions for.
+
+ rules : list[PermissionsRule]
+ The list of permissions rules to update the project with.
+
+ Returns
+ -------
+ list[PermissionsRule]
+ Returns the updated list of permissions rules.
+ """
+ return self._default_permissions.update_default_permissions(item, rules, Resource.VirtualConnection)
+
+ @api(version="3.23")
+ def update_database_default_permissions(
+ self, item: ProjectItem, rules: list[PermissionsRule]
+ ) -> list[PermissionsRule]:
+ """
+ Add or updates the default database permissions for the specified.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content
+
+ Parameters
+ ----------
+ item : ProjectItem
+ The project item to update default database permissions for.
+
+ rules : list[PermissionsRule]
+ The list of permissions rules to update the project with.
+
+ Returns
+ -------
+ list[PermissionsRule]
+ Returns the updated list of permissions rules.
+ """
+ return self._default_permissions.update_default_permissions(item, rules, Resource.Database)
+
+ @api(version="3.23")
+ def update_table_default_permissions(
+ self, item: ProjectItem, rules: list[PermissionsRule]
+ ) -> list[PermissionsRule]:
+ """
+ Add or updates the default table permissions for the specified.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content
+
+ Parameters
+ ----------
+ item : ProjectItem
+ The project item to update default table permissions for.
+
+ rules : list[PermissionsRule]
+ The list of permissions rules to update the project with.
+
+ Returns
+ -------
+ list[PermissionsRule]
+ Returns the updated list of permissions rules.
+ """
+ return self._default_permissions.update_default_permissions(item, rules, Resource.Table)
@api(version="2.1")
- def delete_workbook_default_permissions(self, item, rule):
- self._default_permissions.delete_default_permission(item, rule, Permission.Resource.Workbook)
+ def delete_workbook_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None:
+ """
+ Deletes the specified default permission rule from the project.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission
+
+ Parameters
+ ----------
+ item : ProjectItem
+ The project item to delete default workbook permissions from.
+
+ rule : PermissionsRule
+ The permissions rule to delete from the project.
+
+ Returns
+ -------
+ None
+ """
+ self._default_permissions.delete_default_permission(item, rule, Resource.Workbook)
@api(version="2.1")
- def delete_datasource_default_permissions(self, item, rule):
- self._default_permissions.delete_default_permission(item, rule, Permission.Resource.Datasource)
+ def delete_datasource_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None:
+ """
+ Deletes the specified default permission rule from the project.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission
+
+ Parameters
+ ----------
+ item : ProjectItem
+ The project item to delete default datasource permissions from.
+
+ rule : PermissionsRule
+ The permissions rule to delete from the project.
+
+ Returns
+ -------
+ None
+ """
+ self._default_permissions.delete_default_permission(item, rule, Resource.Datasource)
+
+ @api(version="3.2")
+ def delete_metric_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None:
+ """
+ Deletes the specified default permission rule from the project.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission
+
+ Parameters
+ ----------
+ item : ProjectItem
+ The project item to delete default workbook permissions from.
+
+ rule : PermissionsRule
+ The permissions rule to delete from the project.
+
+ Returns
+ -------
+ None
+ """
+ self._default_permissions.delete_default_permission(item, rule, Resource.Metric)
@api(version="3.4")
- def delete_flow_default_permissions(self, item, rule):
- self._default_permissions.delete_default_permission(item, rule, Permission.Resource.Flow)
+ def delete_datarole_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None:
+ """
+ Deletes the specified default permission rule from the project.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission
+
+ Parameters
+ ----------
+ item : ProjectItem
+ The project item to delete default datarole permissions from.
+
+ rule : PermissionsRule
+ The permissions rule to delete from the project.
+
+ Returns
+ -------
+ None
+ """
+ self._default_permissions.delete_default_permission(item, rule, Resource.Datarole)
+
+ @api(version="3.4")
+ def delete_flow_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None:
+ """
+ Deletes the specified default permission rule from the project.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission
+
+ Parameters
+ ----------
+ item : ProjectItem
+ The project item to delete default flow permissions from.
+
+ rule : PermissionsRule
+ The permissions rule to delete from the project.
+
+ Returns
+ -------
+ None
+ """
+ self._default_permissions.delete_default_permission(item, rule, Resource.Flow)
+
+ @api(version="3.4")
+ def delete_lens_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None:
+ """
+ Deletes the specified default permission rule from the project.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission
+
+ Parameters
+ ----------
+ item : ProjectItem
+ The project item to delete default lens permissions from.
+
+ rule : PermissionsRule
+ The permissions rule to delete from the project.
+
+ Returns
+ -------
+ None
+ """
+ self._default_permissions.delete_default_permission(item, rule, Resource.Lens)
+
+ @api(version="3.23")
+ def delete_virtualconnection_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None:
+ """
+ Deletes the specified default permission rule from the project.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission
+
+ Parameters
+ ----------
+ item : ProjectItem
+ The project item to delete default virtualconnection permissions from.
+
+ rule : PermissionsRule
+ The permissions rule to delete from the project.
+
+ Returns
+ -------
+ None
+ """
+ self._default_permissions.delete_default_permission(item, rule, Resource.VirtualConnection)
+
+ @api(version="3.23")
+ def delete_database_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None:
+ """
+ Deletes the specified default permission rule from the project.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission
+
+ Parameters
+ ----------
+ item : ProjectItem
+ The project item to delete default database permissions from.
+
+ rule : PermissionsRule
+ The permissions rule to delete from the project.
+
+ Returns
+ -------
+ None
+ """
+ self._default_permissions.delete_default_permission(item, rule, Resource.Database)
+
+ @api(version="3.23")
+ def delete_table_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None:
+ """
+ Deletes the specified default permission rule from the project.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission
+
+ Parameters
+ ----------
+ item : ProjectItem
+ The project item to delete default table permissions from.
+
+ rule : PermissionsRule
+ The permissions rule to delete from the project.
+
+ Returns
+ -------
+ None
+ """
+ self._default_permissions.delete_default_permission(item, rule, Resource.Table)
+
+ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[ProjectItem]:
+ """
+ Queries the Tableau Server for items using the specified filters. Page
+ size can be specified to limit the number of items returned in a single
+ request. If not specified, the default page size is 100. Page size can
+ be an integer between 1 and 1000.
+
+ No positional arguments are allowed. All filters must be specified as
+ keyword arguments. If you use the equality operator, you can specify it
+ through =. If you want to use a different operator,
+ you can specify it through __=. Field
+ names can either be in snake_case or camelCase.
+
+ This endpoint supports the following fields and operators:
+
+
+ created_at=...
+ created_at__gt=...
+ created_at__gte=...
+ created_at__lt=...
+ created_at__lte=...
+ name=...
+ name__in=...
+ owner_domain=...
+ owner_domain__in=...
+ owner_email=...
+ owner_email__in=...
+ owner_name=...
+ owner_name__in=...
+ parent_project_id=...
+ parent_project_id__in=...
+ top_level_project=...
+ updated_at=...
+ updated_at__gt=...
+ updated_at__gte=...
+ updated_at__lt=...
+ updated_at__lte=...
+ """
+
+ return super().filter(*invalid, page_size=page_size, **kwargs)
diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py
index a38c66ebe..63c03b3e3 100644
--- a/tableauserverclient/server/endpoint/resource_tagger.py
+++ b/tableauserverclient/server/endpoint/resource_tagger.py
@@ -1,18 +1,31 @@
-from .endpoint import Endpoint
-from .exceptions import EndpointUnavailableError, ServerResponseError
-from .. import RequestFactory
-from ...models.tag_item import TagItem
-import logging
+import abc
import copy
+from typing import Generic, Optional, Protocol, TypeVar, Union, TYPE_CHECKING, runtime_checkable
+from collections.abc import Iterable
import urllib.parse
-logger = logging.getLogger("tableau.endpoint.resource_tagger")
+from tableauserverclient.server.endpoint.endpoint import Endpoint, api
+from tableauserverclient.server.endpoint.exceptions import ServerResponseError
+from tableauserverclient.server.exceptions import EndpointUnavailableError
+from tableauserverclient.server import RequestFactory
+from tableauserverclient.models import TagItem
+
+from tableauserverclient.helpers.logging import logger
+
+if TYPE_CHECKING:
+ from tableauserverclient.models.column_item import ColumnItem
+ from tableauserverclient.models.database_item import DatabaseItem
+ from tableauserverclient.models.datasource_item import DatasourceItem
+ from tableauserverclient.models.flow_item import FlowItem
+ from tableauserverclient.models.table_item import TableItem
+ from tableauserverclient.models.workbook_item import WorkbookItem
+ from tableauserverclient.server.server import Server
class _ResourceTagger(Endpoint):
# Add new tags to resource
def _add_tags(self, baseurl, resource_id, tag_set):
- url = "{0}/{1}/tags".format(baseurl, resource_id)
+ url = f"{baseurl}/{resource_id}/tags"
add_req = RequestFactory.Tag.add_req(tag_set)
try:
@@ -27,7 +40,7 @@ def _add_tags(self, baseurl, resource_id, tag_set):
# Delete a resource's tag by name
def _delete_tag(self, baseurl, resource_id, tag_name):
encoded_tag_name = urllib.parse.quote(tag_name)
- url = "{0}/{1}/tags/{2}".format(baseurl, resource_id, encoded_tag_name)
+ url = f"{baseurl}/{resource_id}/tags/{encoded_tag_name}"
try:
self.delete_request(url)
@@ -47,4 +60,125 @@ def update_tags(self, baseurl, resource_item):
if add_set:
resource_item.tags = self._add_tags(baseurl, resource_item.id, add_set)
resource_item._initial_tags = copy.copy(resource_item.tags)
- logger.info("Updated tags to {0}".format(resource_item.tags))
+ logger.info(f"Updated tags to {resource_item.tags}")
+
+
+class Response(Protocol):
+ content: bytes
+
+
+@runtime_checkable
+class Taggable(Protocol):
+ tags: set[str]
+ _initial_tags: set[str]
+
+ @property
+ def id(self) -> Optional[str]:
+ pass
+
+
+T = TypeVar("T")
+
+
+class TaggingMixin(abc.ABC, Generic[T]):
+ parent_srv: "Server"
+
+ @property
+ @abc.abstractmethod
+ def baseurl(self) -> str:
+ pass
+
+ @abc.abstractmethod
+ def put_request(self, url, request) -> Response:
+ pass
+
+ @abc.abstractmethod
+ def delete_request(self, url) -> None:
+ pass
+
+ def add_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> set[str]:
+ item_id = getattr(item, "id", item)
+
+ if not isinstance(item_id, str):
+ raise ValueError("ID not found.")
+
+ if isinstance(tags, str):
+ tag_set = {tags}
+ else:
+ tag_set = set(tags)
+
+ url = f"{self.baseurl}/{item_id}/tags"
+ add_req = RequestFactory.Tag.add_req(tag_set)
+ server_response = self.put_request(url, add_req)
+ return TagItem.from_response(server_response.content, self.parent_srv.namespace)
+
+ def delete_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> None:
+ item_id = getattr(item, "id", item)
+
+ if not isinstance(item_id, str):
+ raise ValueError("ID not found.")
+
+ if isinstance(tags, str):
+ tag_set = {tags}
+ else:
+ tag_set = set(tags)
+
+ for tag in tag_set:
+ encoded_tag_name = urllib.parse.quote(tag)
+ url = f"{self.baseurl}/{item_id}/tags/{encoded_tag_name}"
+ self.delete_request(url)
+
+ def update_tags(self, item: T) -> None:
+ if (initial_tags := getattr(item, "_initial_tags", None)) is None:
+ raise ValueError(f"{item} does not have initial tags.")
+ if (tags := getattr(item, "tags", None)) is None:
+ raise ValueError(f"{item} does not have tags.")
+ if tags == initial_tags:
+ return
+
+ add_set = tags - initial_tags
+ remove_set = initial_tags - tags
+ self.delete_tags(item, remove_set)
+ if add_set:
+ tags = self.add_tags(item, add_set)
+ setattr(item, "tags", tags)
+
+ setattr(item, "_initial_tags", copy.copy(tags))
+ logger.info(f"Updated tags to {tags}")
+
+
+content = Iterable[Union["ColumnItem", "DatabaseItem", "DatasourceItem", "FlowItem", "TableItem", "WorkbookItem"]]
+
+
+class Tags(Endpoint):
+ def __init__(self, parent_srv: "Server"):
+ super().__init__(parent_srv)
+
+ @property
+ def baseurl(self):
+ return f"{self.parent_srv.baseurl}/tags"
+
+ @api(version="3.9")
+ def batch_add(self, tags: Union[Iterable[str], str], content: content) -> set[str]:
+ if isinstance(tags, str):
+ tag_set = {tags}
+ else:
+ tag_set = set(tags)
+
+ url = f"{self.baseurl}:batchCreate"
+ batch_create_req = RequestFactory.Tag.batch_create(tag_set, content)
+ server_response = self.put_request(url, batch_create_req)
+ return TagItem.from_response(server_response.content, self.parent_srv.namespace)
+
+ @api(version="3.9")
+ def batch_delete(self, tags: Union[Iterable[str], str], content: content) -> set[str]:
+ if isinstance(tags, str):
+ tag_set = {tags}
+ else:
+ tag_set = set(tags)
+
+ url = f"{self.baseurl}:batchDelete"
+ # The batch delete XML is the same as the batch create XML.
+ batch_delete_req = RequestFactory.Tag.batch_create(tag_set, content)
+ server_response = self.put_request(url, batch_delete_req)
+ return TagItem.from_response(server_response.content, self.parent_srv.namespace)
diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py
index d582dca26..090d400b6 100644
--- a/tableauserverclient/server/endpoint/schedules_endpoint.py
+++ b/tableauserverclient/server/endpoint/schedules_endpoint.py
@@ -1,27 +1,52 @@
-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, Optional, Union
+
+from .endpoint import Endpoint, api, parameter_added_in
+from .exceptions import MissingRequiredFieldError
+from tableauserverclient.server import RequestFactory
+from tableauserverclient.models import PaginationItem, ScheduleItem, TaskItem, ExtractItem
+
+from tableauserverclient.helpers.logging import logger
-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):
- return "{0}/schedules".format(self.parent_srv.baseurl)
+ def baseurl(self) -> str:
+ return f"{self.parent_srv.baseurl}/schedules"
@property
- def siteurl(self):
- return "{0}/sites/{1}/schedules".format(self.parent_srv.baseurl, self.parent_srv.site_id)
+ def siteurl(self) -> str:
+ return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/schedules"
@api(version="2.3")
- def get(self, req_options=None):
+ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[ScheduleItem], PaginationItem]:
+ """
+ Returns a list of flows, extract, and subscription server schedules on
+ Tableau Server. For each schedule, the API returns name, frequency,
+ priority, and other information.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#query_schedules
+
+ Parameters
+ ----------
+ req_options : Optional[RequestOptions]
+ Filtering and paginating options for request.
+
+ Returns
+ -------
+ Tuple[List[ScheduleItem], PaginationItem]
+ A tuple of list of ScheduleItem and PaginationItem
+ """
logger.info("Querying all schedules")
url = self.baseurl
server_response = self.get_request(url, req_options)
@@ -29,30 +54,100 @@ 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: str) -> ScheduleItem:
+ """
+ Returns detailed information about the specified server schedule.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#get-schedule
+
+ Parameters
+ ----------
+ schedule_id : str
+ The ID of the schedule to get information for.
+
+ Returns
+ -------
+ ScheduleItem
+ The schedule item that corresponds to the given ID.
+ """
+ if not schedule_id:
+ error = "No Schedule ID provided"
+ raise ValueError(error)
+ logger.info(f"Querying a single schedule by id ({schedule_id})")
+ url = f"{self.baseurl}/{schedule_id}"
+ server_response = self.get_request(url)
+ return ScheduleItem.from_response(server_response.content, self.parent_srv.namespace)[0]
+
@api(version="2.3")
- def delete(self, schedule_id):
+ def delete(self, schedule_id: str) -> None:
+ """
+ Deletes the specified schedule from the server.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#delete_schedule
+
+ Parameters
+ ----------
+ schedule_id : str
+ The ID of the schedule to delete.
+
+ Returns
+ -------
+ None
+ """
if not schedule_id:
error = "Schedule ID undefined"
raise ValueError(error)
- url = "{0}/{1}".format(self.baseurl, schedule_id)
+ url = f"{self.baseurl}/{schedule_id}"
self.delete_request(url)
- logger.info("Deleted single schedule (ID: {0})".format(schedule_id))
+ logger.info(f"Deleted single schedule (ID: {schedule_id})")
@api(version="2.3")
- def update(self, schedule_item):
+ def update(self, schedule_item: ScheduleItem) -> ScheduleItem:
+ """
+ Modifies settings for the specified server schedule, including the name,
+ priority, and frequency details on Tableau Server. For Tableau Cloud,
+ see the tasks and subscritpions API.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#update_schedule
+
+ Parameters
+ ----------
+ schedule_item : ScheduleItem
+ The schedule item to update.
+
+ Returns
+ -------
+ ScheduleItem
+ The updated schedule item.
+ """
if not schedule_item.id:
error = "Schedule item missing ID."
raise MissingRequiredFieldError(error)
- url = "{0}/{1}".format(self.baseurl, schedule_item.id)
+ url = f"{self.baseurl}/{schedule_item.id}"
update_req = RequestFactory.Schedule.update_req(schedule_item)
server_response = self.put_request(url, update_req)
- logger.info("Updated schedule item (ID: {})".format(schedule_item.id))
+ logger.info(f"Updated schedule item (ID: {schedule_item.id})")
updated_schedule = copy.copy(schedule_item)
return updated_schedule._parse_common_tags(server_response.content, self.parent_srv.namespace)
@api(version="2.3")
- def create(self, schedule_item):
+ def create(self, schedule_item: ScheduleItem) -> ScheduleItem:
+ """
+ Creates a new server schedule on Tableau Server. For Tableau Cloud, use
+ the tasks and subscriptions API.
+
+ Parameters
+ ----------
+ schedule_item : ScheduleItem
+ The schedule item to create.
+
+ Returns
+ -------
+ ScheduleItem
+ The newly created schedule.
+ """
if schedule_item.interval_item is None:
error = "Interval item must be defined."
raise MissingRequiredFieldError(error)
@@ -61,46 +156,126 @@ def create(self, schedule_item):
create_req = RequestFactory.Schedule.create_req(schedule_item)
server_response = self.post_request(url, create_req)
new_schedule = ScheduleItem.from_response(server_response.content, self.parent_srv.namespace)[0]
- logger.info("Created new schedule (ID: {})".format(new_schedule.id))
+ logger.info(f"Created new schedule (ID: {new_schedule.id})")
return new_schedule
@api(version="2.8")
+ @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))
+ schedule_id: str,
+ workbook: Optional["WorkbookItem"] = None,
+ datasource: Optional["DatasourceItem"] = None,
+ flow: Optional["FlowItem"] = None,
+ task_type: Optional[str] = None,
+ ) -> list[AddResponse]:
+ """
+ Adds a workbook, datasource, or flow to a schedule on Tableau Server.
+ Only one of workbook, datasource, or flow can be passed in at a time.
+
+ The task type is optional and will default to ExtractRefresh if a
+ workbook or datasource is passed in, and RunFlow if a flow is passed in.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#add_workbook_to_schedule
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#add_data_source_to_schedule
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#add_flow_task_to_schedule
+
+ Parameters
+ ----------
+ schedule_id : str
+ The ID of the schedule to add the item to.
+
+ workbook : Optional[WorkbookItem]
+ The workbook to add to the schedule.
- 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
+ datasource : Optional[DatasourceItem]
+ The datasource to add to the schedule.
- items = []
+ flow : Optional[FlowItem]
+ The flow to add to the schedule.
+
+ task_type : Optional[str]
+ The type of task to add to the schedule. If not provided, it will
+ default to ExtractRefresh if a workbook or datasource is passed in,
+ and RunFlow if a flow is passed in.
+
+ Returns
+ -------
+ list[AddResponse]
+ A list of responses for each item added to the schedule.
+ """
+ # There doesn't seem to be a good reason to allow one item of each type?
+ if workbook and datasource:
+ warnings.warn("Passing in multiple items for add_to_schedule will be deprecated", PendingDeprecationWarning)
+ 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))
+ 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)
+ return [x for x in results if not x.result]
+
+ 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 = f"{self.siteurl}/{schedule_id}/{type_}s"
+ add_req = req_factory(id_, task_type=item_task_type) # type: ignore[call-arg, arg-type]
+ response = self.put_request(url, add_req)
+
+ error, warnings, task_created = ScheduleItem.parse_add_to_schedule_response(response, self.parent_srv.namespace)
+ if task_created:
+ logger.info(f"Added {type_} to {id_} to schedule {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
+
+ @api(version="2.3")
+ def get_extract_refresh_tasks(
+ self, schedule_id: str, req_options: Optional["RequestOptions"] = None
+ ) -> tuple[list["ExtractItem"], "PaginationItem"]:
+ """Get all extract refresh tasks for the specified schedule."""
+ if not schedule_id:
+ error = "Schedule ID undefined"
+ raise ValueError(error)
+
+ logger.info(f"Querying extract refresh tasks for schedule (ID: {schedule_id})")
+ url = f"{self.siteurl}/{schedule_id}/extracts"
+ server_response = self.get_request(url, req_options)
+
+ pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace)
+ extract_items = ExtractItem.from_response(server_response.content, self.parent_srv.namespace)
- results = (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 extract_items, pagination_item
diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py
index ca3715fca..dc934496a 100644
--- a/tableauserverclient/server/endpoint/server_info_endpoint.py
+++ b/tableauserverclient/server/endpoint/server_info_endpoint.py
@@ -1,30 +1,82 @@
+import logging
+from typing import Union
+
from .endpoint import Endpoint, api
-from .exceptions import (
- ServerResponseError,
+from .exceptions import ServerResponseError
+from ..exceptions import (
ServerInfoEndpointNotFoundError,
EndpointUnavailableError,
)
-from ...models import ServerInfoItem
-import logging
-
-logger = logging.getLogger("tableau.endpoint.server_info")
+from tableauserverclient.models import ServerInfoItem
class ServerInfo(Endpoint):
+ def __init__(self, server):
+ self.parent_srv = server
+ self._info = None
+
@property
- def baseurl(self):
- return "{0}/serverInfo".format(self.parent_srv.baseurl)
+ def serverInfo(self):
+ if not self._info:
+ self.get()
+ return self._info
+
+ def __repr__(self):
+ return f""
+
+ @property
+ def baseurl(self) -> str:
+ return f"{self.parent_srv.baseurl}/serverInfo"
@api(version="2.4")
- def get(self):
- """Retrieve the server info for the server. This is an unauthenticated call"""
+ def get(self) -> Union[ServerInfoItem, None]:
+ """
+ Retrieve the build and version information for the server.
+
+ This method makes an unauthenticated call, so no sign in or
+ authentication token is required.
+
+ Returns
+ -------
+ :class:`~tableauserverclient.models.ServerInfoItem`
+
+ Raises
+ ------
+ :class:`~tableauserverclient.exceptions.ServerInfoEndpointNotFoundError`
+ Raised when the server info endpoint is not found.
+
+ :class:`~tableauserverclient.exceptions.EndpointUnavailableError`
+ Raised when the server info endpoint is not available.
+
+ Examples
+ --------
+ >>> import tableauserverclient as TSC
+
+ >>> # create a instance of server
+ >>> server = TSC.Server('https://MY-SERVER')
+
+ >>> # set the version number > 2.3
+ >>> # the server_info.get() method works in 2.4 and later
+ >>> server.version = '2.5'
+
+ >>> s_info = server.server_info.get()
+ >>> print("\nServer info:")
+ >>> print("\tProduct version: {0}".format(s_info.product_version))
+ >>> print("\tREST API version: {0}".format(s_info.rest_api_version))
+ >>> print("\tBuild number: {0}".format(s_info.build_number))
+ """
try:
server_response = self.get_unauthenticated_request(self.baseurl)
except ServerResponseError as e:
if e.code == "404003":
- raise ServerInfoEndpointNotFoundError
+ raise ServerInfoEndpointNotFoundError(e)
if e.code == "404001":
- raise EndpointUnavailableError
+ raise EndpointUnavailableError(e)
+ raise e
- server_info = ServerInfoItem.from_response(server_response.content, self.parent_srv.namespace)
- return server_info
+ try:
+ self._info = ServerInfoItem.from_response(server_response.content, self.parent_srv.namespace)
+ except Exception as e:
+ logging.getLogger(self.__class__.__name__).debug(e)
+ logging.getLogger(self.__class__.__name__).debug(server_response.content)
+ return self._info
diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py
index 9446a01a8..e2316fbb8 100644
--- a/tableauserverclient/server/endpoint/sites_endpoint.py
+++ b/tableauserverclient/server/endpoint/sites_endpoint.py
@@ -1,22 +1,58 @@
+import copy
+import logging
+
from .endpoint import Endpoint, api
from .exceptions import MissingRequiredFieldError
-from .. import RequestFactory, SiteItem, PaginationItem
+from tableauserverclient.server import RequestFactory
+from tableauserverclient.models import SiteAuthConfiguration, SiteItem, PaginationItem
-import copy
-import logging
+from tableauserverclient.helpers.logging import logger
-logger = logging.getLogger("tableau.endpoint.sites")
+from typing import TYPE_CHECKING, Optional
+
+if TYPE_CHECKING:
+ from ..request_options import RequestOptions
class Sites(Endpoint):
+ """
+ Using the site methods of the Tableau Server REST API you can:
+
+ List sites on a server or get details of a specific site
+ Create, update, or delete a site
+ List views in a site
+ Encrypt, decrypt, or reencrypt extracts on a site
+
+ """
+
@property
- def baseurl(self):
- return "{0}/sites".format(self.parent_srv.baseurl)
+ def baseurl(self) -> str:
+ return f"{self.parent_srv.baseurl}/sites"
# 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]:
+ """
+ Query all sites on the server. This method requires server admin
+ permissions. This endpoint is paginated, meaning that the server will
+ only return a subset of the data at a time. The response will contain
+ information about the total number of sites and the number of sites
+ returned in the current response. Use the PaginationItem object to
+ request more data.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_sites
+
+ Parameters
+ ----------
+ req_options : RequestOptions, optional
+ Filtering options for the request.
+
+ Returns
+ -------
+ tuple[list[SiteItem], PaginationItem]
+ """
logger.info("Querying all sites on site")
+ logger.info("Requires Server Admin permissions")
url = self.baseurl
server_response = self.get_request(url, req_options)
pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace)
@@ -25,109 +61,377 @@ 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:
+ """
+ Query a single site on the server. You can only retrieve the site that
+ you are currently authenticated for.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_site
+
+ Parameters
+ ----------
+ site_id : str
+ The site ID.
+
+ Returns
+ -------
+ SiteItem
+
+ Raises
+ ------
+ ValueError
+ If the site ID is not defined.
+
+ ValueError
+ If the site ID does not match the site for which you are currently authenticated.
+
+ Examples
+ --------
+ >>> site = server.sites.get_by_id('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p')
+ """
if not site_id:
error = "Site ID undefined."
raise ValueError(error)
- logger.info("Querying single site (ID: {0})".format(site_id))
- url = "{0}/{1}".format(self.baseurl, site_id)
+ if not site_id == self.parent_srv.site_id:
+ error = "You can only retrieve the site for which you are currently authenticated."
+ raise ValueError(error)
+
+ logger.info(f"Querying single site (ID: {site_id})")
+ url = f"{self.baseurl}/{site_id}"
server_response = self.get_request(url)
return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0]
# Gets 1 site by name
@api(version="2.0")
- def get_by_name(self, site_name):
+ def get_by_name(self, site_name: str) -> SiteItem:
+ """
+ Query a single site on the server. You can only retrieve the site that
+ you are currently authenticated for.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_site
+
+ Parameters
+ ----------
+ site_name : str
+ The site name.
+
+ Returns
+ -------
+ SiteItem
+
+ Raises
+ ------
+ ValueError
+ If the site name is not defined.
+
+ Examples
+ --------
+ >>> site = server.sites.get_by_name('Tableau')
+
+ """
if not site_name:
error = "Site Name undefined."
raise ValueError(error)
- logger.info("Querying single site (Name: {0})".format(site_name))
- url = "{0}/{1}?key=name".format(self.baseurl, site_name)
+ print("Note: You can only work with the site for which you are currently authenticated")
+ logger.info(f"Querying single site (Name: {site_name})")
+ url = f"{self.baseurl}/{site_name}?key=name"
+ print(self.baseurl, url)
server_response = self.get_request(url)
return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0]
# 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:
+ """
+ Query a single site on the server. You can only retrieve the site that
+ you are currently authenticated for.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_site
+
+ Parameters
+ ----------
+ content_url : str
+ The content URL.
+
+ Returns
+ -------
+ SiteItem
+
+ Raises
+ ------
+ ValueError
+ If the site name is not defined.
+
+ Examples
+ --------
+ >>> site = server.sites.get_by_name('Tableau')
+
+ """
if content_url is None:
error = "Content URL undefined."
raise ValueError(error)
- logger.info("Querying single site (Content URL: {0})".format(content_url))
- url = "{0}/{1}?key=contentUrl".format(self.baseurl, content_url)
+ if not self.parent_srv.baseurl.index(content_url) > 0:
+ error = "You can only work with the site you are currently authenticated for"
+ raise ValueError(error)
+
+ logger.info(f"Querying single site (Content URL: {content_url})")
+ logger.debug("Querying other sites requires Server Admin permissions")
+ url = f"{self.baseurl}/{content_url}?key=contentUrl"
server_response = self.get_request(url)
return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0]
# Update site
@api(version="2.0")
- def update(self, site_item):
+ def update(self, site_item: SiteItem) -> SiteItem:
+ """
+ Modifies the settings for site.
+
+ The site item object must include the site ID and overrides all other settings.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#update_site
+
+ Parameters
+ ----------
+ site_item : SiteItem
+ The site item that you want to update. The settings specified in the
+ site item override the current site settings.
+
+ Returns
+ -------
+ SiteItem
+ The site item object that was updated.
+
+ Raises
+ ------
+ MissingRequiredFieldError
+ If the site item is missing an ID.
+
+ ValueError
+ If the site ID does not match the site for which you are currently authenticated.
+
+ ValueError
+ If the site admin mode is set to ContentOnly and a user quota is also set.
+
+ Examples
+ --------
+ >>> ...
+ >>> site_item.name = 'New Name'
+ >>> updated_site = server.sites.update(site_item)
+
+ """
if not site_item.id:
error = "Site item missing ID."
raise MissingRequiredFieldError(error)
+ print(self.parent_srv.site_id, site_item.id)
+ if not site_item.id == self.parent_srv.site_id:
+ error = "You can only update the site you are currently authenticated for"
+ raise ValueError(error)
+
if site_item.admin_mode:
if site_item.admin_mode == SiteItem.AdminMode.ContentOnly and site_item.user_quota:
error = "You cannot set admin_mode to ContentOnly and also set a user quota"
raise ValueError(error)
- url = "{0}/{1}".format(self.baseurl, site_item.id)
- update_req = RequestFactory.Site.update_req(site_item)
+ url = f"{self.baseurl}/{site_item.id}"
+ update_req = RequestFactory.Site.update_req(site_item, self.parent_srv)
server_response = self.put_request(url, update_req)
- logger.info("Updated site item (ID: {0})".format(site_item.id))
+ logger.info(f"Updated site item (ID: {site_item.id})")
update_site = copy.copy(site_item)
return update_site._parse_common_tags(server_response.content, self.parent_srv.namespace)
# Delete 1 site object
@api(version="2.0")
- def delete(self, site_id):
+ def delete(self, site_id: str) -> None:
+ """
+ Deletes the specified site from the server. You can only delete the site
+ if you are a Server Admin.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#delete_site
+
+ Parameters
+ ----------
+ site_id : str
+ The site ID.
+
+ Raises
+ ------
+ ValueError
+ If the site ID is not defined.
+
+ ValueError
+ If the site ID does not match the site for which you are currently authenticated.
+
+ Examples
+ --------
+ >>> server.sites.delete('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p')
+ """
if not site_id:
error = "Site ID undefined."
raise ValueError(error)
- url = "{0}/{1}".format(self.baseurl, site_id)
+ url = f"{self.baseurl}/{site_id}"
+ if not site_id == self.parent_srv.site_id:
+ error = "You can only delete the site you are currently authenticated for"
+ raise ValueError(error)
self.delete_request(url)
- # If we deleted the site we are logged into
- # then we are automatically logged out
- if site_id == self.parent_srv.site_id:
- logger.info("Deleting current site and clearing auth tokens")
- self.parent_srv._clear_auth()
- logger.info("Deleted single site (ID: {0}) and signed out".format(site_id))
+ self.parent_srv._clear_auth()
+ logger.info(f"Deleted single site (ID: {site_id}) and signed out")
# Create new site
@api(version="2.0")
- def create(self, site_item):
+ def create(self, site_item: SiteItem) -> SiteItem:
+ """
+ Creates a new site on the server for the specified site item object.
+
+ Tableau Server only.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#create_site
+
+ Parameters
+ ----------
+ site_item : SiteItem
+ The settings for the site that you want to create. You need to
+ create an instance of SiteItem and pass it to the create method.
+
+ Returns
+ -------
+ SiteItem
+ The site item object that was created.
+
+ Raises
+ ------
+ ValueError
+ If the site admin mode is set to ContentOnly and a user quota is also set.
+
+ Examples
+ --------
+ >>> import tableauserverclient as TSC
+
+ >>> # create an instance of server
+ >>> server = TSC.Server('https://MY-SERVER')
+
+ >>> # create shortcut for admin mode
+ >>> content_users=TSC.SiteItem.AdminMode.ContentAndUsers
+
+ >>> # create a new SiteItem
+ >>> new_site = TSC.SiteItem(name='Tableau', content_url='tableau', admin_mode=content_users, user_quota=15, storage_quota=1000, disable_subscriptions=True)
+
+ >>> # call the sites create method with the SiteItem
+ >>> new_site = server.sites.create(new_site)
+
+
+ """
if site_item.admin_mode:
if site_item.admin_mode == SiteItem.AdminMode.ContentOnly and site_item.user_quota:
error = "You cannot set admin_mode to ContentOnly and also set a user quota"
raise ValueError(error)
url = self.baseurl
- create_req = RequestFactory.Site.create_req(site_item)
+ create_req = RequestFactory.Site.create_req(site_item, self.parent_srv)
server_response = self.post_request(url, create_req)
new_site = SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0]
- logger.info("Created new site (ID: {0})".format(new_site.id))
+ logger.info(f"Created new site (ID: {new_site.id})")
return new_site
@api(version="3.5")
- def encrypt_extracts(self, site_id):
+ def encrypt_extracts(self, site_id: str) -> None:
+ """
+ Encrypts all extracts on the site.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#encrypt_extracts
+
+ Parameters
+ ----------
+ site_id : str
+ The site ID.
+
+ Raises
+ ------
+ ValueError
+ If the site ID is not defined.
+
+ Examples
+ --------
+ >>> server.sites.encrypt_extracts('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p')
+ """
if not site_id:
error = "Site ID undefined."
raise ValueError(error)
- url = "{0}/{1}/encrypt-extracts".format(self.baseurl, site_id)
+ url = f"{self.baseurl}/{site_id}/encrypt-extracts"
empty_req = RequestFactory.Empty.empty_req()
self.post_request(url, empty_req)
@api(version="3.5")
- def decrypt_extracts(self, site_id):
+ def decrypt_extracts(self, site_id: str) -> None:
+ """
+ Decrypts all extracts on the site.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#decrypt_extracts
+
+ Parameters
+ ----------
+ site_id : str
+ The site ID.
+
+ Raises
+ ------
+ ValueError
+ If the site ID is not defined.
+
+ Examples
+ --------
+ >>> server.sites.decrypt_extracts('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p')
+ """
if not site_id:
error = "Site ID undefined."
raise ValueError(error)
- url = "{0}/{1}/decrypt-extracts".format(self.baseurl, site_id)
+ url = f"{self.baseurl}/{site_id}/decrypt-extracts"
empty_req = RequestFactory.Empty.empty_req()
self.post_request(url, empty_req)
@api(version="3.5")
- def re_encrypt_extracts(self, site_id):
+ def re_encrypt_extracts(self, site_id: str) -> None:
+ """
+ Reencrypt all extracts on a site with new encryption keys. If no site is
+ specified, extracts on the default site will be reencrypted.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#reencrypt_extracts
+
+ Parameters
+ ----------
+ site_id : str
+ The site ID.
+
+ Raises
+ ------
+ ValueError
+ If the site ID is not defined.
+
+ Examples
+ --------
+ >>> server.sites.re_encrypt_extracts('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p')
+
+ """
if not site_id:
error = "Site ID undefined."
raise ValueError(error)
- url = "{0}/{1}/reencrypt-extracts".format(self.baseurl, site_id)
+ url = f"{self.baseurl}/{site_id}/reencrypt-extracts"
empty_req = RequestFactory.Empty.empty_req()
self.post_request(url, empty_req)
+
+ @api(version="3.24")
+ def list_auth_configurations(self) -> list[SiteAuthConfiguration]:
+ """
+ Lists all authentication configurations on the current site.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_site.htm#list_authentication_configurations_site
+
+ Returns
+ -------
+ list[SiteAuthConfiguration]
+ A list of authentication configurations on the current site.
+ """
+ url = f"{self.baseurl}/{self.parent_srv.site_id}/site-auth-configurations"
+ server_response = self.get_request(url)
+ auth_configurations = SiteAuthConfiguration.from_response(server_response.content, self.parent_srv.namespace)
+ return auth_configurations
diff --git a/tableauserverclient/server/endpoint/subscriptions_endpoint.py b/tableauserverclient/server/endpoint/subscriptions_endpoint.py
index 1a66e8ac5..c9abc9b06 100644
--- a/tableauserverclient/server/endpoint/subscriptions_endpoint.py
+++ b/tableauserverclient/server/endpoint/subscriptions_endpoint.py
@@ -1,19 +1,25 @@
+import logging
+
from .endpoint import Endpoint, api
from .exceptions import MissingRequiredFieldError
-from .. import RequestFactory, SubscriptionItem, PaginationItem
+from tableauserverclient.server import RequestFactory
+from tableauserverclient.models import SubscriptionItem, PaginationItem
-import logging
+from tableauserverclient.helpers.logging import logger
+
+from typing import Optional, TYPE_CHECKING
-logger = logging.getLogger("tableau.endpoint.subscriptions")
+if TYPE_CHECKING:
+ from ..request_options import RequestOptions
class Subscriptions(Endpoint):
@property
- def baseurl(self):
- return "{0}/sites/{1}/subscriptions".format(self.parent_srv.baseurl, self.parent_srv.site_id)
+ def baseurl(self) -> str:
+ return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/subscriptions"
@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,42 +29,42 @@ 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)
- logger.info("Querying a single subscription by id ({})".format(subscription_id))
- url = "{}/{}".format(self.baseurl, subscription_id)
+ logger.info(f"Querying a single subscription by id ({subscription_id})")
+ url = f"{self.baseurl}/{subscription_id}"
server_response = self.get_request(url)
return SubscriptionItem.from_response(server_response.content, self.parent_srv.namespace)[0]
@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)
- logger.info("Creating a subscription ({})".format(subscription_item))
+ logger.info(f"Creating a subscription ({subscription_item})")
url = self.baseurl
create_req = RequestFactory.Subscription.create_req(subscription_item)
server_response = self.post_request(url, create_req)
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)
- url = "{0}/{1}".format(self.baseurl, subscription_id)
+ url = f"{self.baseurl}/{subscription_id}"
self.delete_request(url)
- logger.info("Deleted subscription (ID: {0})".format(subscription_id))
+ logger.info(f"Deleted subscription (ID: {subscription_id})")
@api(version="2.3")
- def update(self, subscription_item):
+ def update(self, subscription_item: SubscriptionItem) -> SubscriptionItem:
if not subscription_item.id:
error = "Subscription item missing ID. Subscription must be retrieved from server first."
raise MissingRequiredFieldError(error)
- url = "{0}/{1}".format(self.baseurl, subscription_item.id)
+ url = f"{self.baseurl}/{subscription_item.id}"
update_req = RequestFactory.Subscription.update_req(subscription_item)
server_response = self.put_request(url, update_req)
- logger.info("Updated subscription item (ID: {0})".format(subscription_item.id))
+ logger.info(f"Updated subscription item (ID: {subscription_item.id})")
return SubscriptionItem.from_response(server_response.content, self.parent_srv.namespace)[0]
diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py
index ac53484db..ad80e7d0e 100644
--- a/tableauserverclient/server/endpoint/tables_endpoint.py
+++ b/tableauserverclient/server/endpoint/tables_endpoint.py
@@ -1,29 +1,55 @@
-from .endpoint import api, Endpoint
-from .exceptions import MissingRequiredFieldError
-from .permissions_endpoint import _PermissionsEndpoint
-from .dqw_endpoint import _DataQualityWarningEndpoint
-from ..pager import Pager
+import logging
+from typing import Optional, Union, TYPE_CHECKING
+from collections.abc import Iterable
-from .. import RequestFactory, TableItem, ColumnItem, PaginationItem
+from tableauserverclient.models.permissions_item import PermissionsRule
+from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint
+from tableauserverclient.server.endpoint.endpoint import api, Endpoint
+from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError
+from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint
+from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin
+from tableauserverclient.server import RequestFactory
+from tableauserverclient.models import TableItem, ColumnItem, PaginationItem
+from tableauserverclient.server.pager import Pager
-import logging
+from tableauserverclient.helpers.logging import logger
+from tableauserverclient.server.request_options import RequestOptions
-logger = logging.getLogger("tableau.endpoint.tables")
+if TYPE_CHECKING:
+ from tableauserverclient.models import DQWItem, PermissionsRule
-class Tables(Endpoint):
+class Tables(Endpoint, TaggingMixin[TableItem]):
def __init__(self, parent_srv):
- super(Tables, self).__init__(parent_srv)
+ super().__init__(parent_srv)
self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl)
self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "table")
@property
- def baseurl(self):
- return "{0}/sites/{1}/tables".format(self.parent_srv.baseurl, self.parent_srv.site_id)
+ def baseurl(self) -> str:
+ return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tables"
@api(version="3.5")
- def get(self, req_options=None):
+ def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[TableItem], PaginationItem]:
+ """
+ Get information about all tables on the site. Endpoint is paginated, and
+ will return a default of 100 items per page. Use the `req_options`
+ parameter to customize the request.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_tables
+
+ Parameters
+ ----------
+ req_options : RequestOptions, optional
+ Options to customize the request. If not provided, defaults to None.
+
+ Returns
+ -------
+ tuple[list[TableItem], PaginationItem]
+ A tuple containing a list of TableItem objects and a PaginationItem
+ object.
+ """
logger.info("Querying all tables on site")
url = self.baseurl
server_response = self.get_request(url, req_options)
@@ -33,104 +59,205 @@ def get(self, req_options=None):
# Get 1 table
@api(version="3.5")
- def get_by_id(self, table_id):
+ def get_by_id(self, table_id: str) -> TableItem:
+ """
+ Get information about a single table on the site.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_table
+
+ Parameters
+ ----------
+ table_id : str
+ The ID of the table to retrieve.
+
+ Returns
+ -------
+ TableItem
+ A TableItem object representing the table.
+
+ Raises
+ ------
+ ValueError
+ If the table ID is not provided.
+ """
if not table_id:
error = "table ID undefined."
raise ValueError(error)
- logger.info("Querying single table (ID: {0})".format(table_id))
- url = "{0}/{1}".format(self.baseurl, table_id)
+ logger.info(f"Querying single table (ID: {table_id})")
+ url = f"{self.baseurl}/{table_id}"
server_response = self.get_request(url)
return TableItem.from_response(server_response.content, self.parent_srv.namespace)[0]
@api(version="3.5")
- def delete(self, table_id):
+ def delete(self, table_id: str) -> None:
+ """
+ Delete a single table from the server.
+
+ Parameters
+ ----------
+ table_id : str
+ The ID of the table to delete.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ ValueError
+ If the table ID is not provided.
+ """
if not table_id:
error = "Database ID undefined."
raise ValueError(error)
- url = "{0}/{1}".format(self.baseurl, table_id)
+ url = f"{self.baseurl}/{table_id}"
self.delete_request(url)
- logger.info("Deleted single table (ID: {0})".format(table_id))
+ logger.info(f"Deleted single table (ID: {table_id})")
@api(version="3.5")
- def update(self, table_item):
+ def update(self, table_item: TableItem) -> TableItem:
+ """
+ Update a table on the server.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#update_table
+
+ Parameters
+ ----------
+ table_item : TableItem
+ The TableItem object to update.
+
+ Returns
+ -------
+ TableItem
+ The updated TableItem object.
+
+ Raises
+ ------
+ MissingRequiredFieldError
+ If the table item is missing an ID.
+ """
if not table_item.id:
error = "table item missing ID."
raise MissingRequiredFieldError(error)
- url = "{0}/{1}".format(self.baseurl, table_item.id)
+ url = f"{self.baseurl}/{table_item.id}"
update_req = RequestFactory.Table.update_req(table_item)
server_response = self.put_request(url, update_req)
- logger.info("Updated table item (ID: {0})".format(table_item.id))
+ logger.info(f"Updated table item (ID: {table_item.id})")
updated_table = TableItem.from_response(server_response.content, self.parent_srv.namespace)[0]
return updated_table
# Get all columns of the table
@api(version="3.5")
- def populate_columns(self, table_item, req_options=None):
+ def populate_columns(self, table_item: TableItem, req_options: Optional[RequestOptions] = None) -> None:
+ """
+ Populate the columns of a table item. Sets a fetcher function to
+ retrieve the columns when needed.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_columns
+
+ Parameters
+ ----------
+ table_item : TableItem
+ The TableItem object to populate columns for.
+
+ req_options : RequestOptions, optional
+ Options to customize the request. If not provided, defaults to None.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ MissingRequiredFieldError
+ If the table item is missing an ID.
+ """
if not table_item.id:
error = "Table item missing ID. table must be retrieved from server first."
raise MissingRequiredFieldError(error)
def column_fetcher():
return Pager(
- lambda options: self._get_columns_for_table(table_item, options),
+ lambda options: self._get_columns_for_table(table_item, options), # type: ignore
req_options,
)
table_item._set_columns(column_fetcher)
- logger.info("Populated columns for table (ID: {0}".format(table_item.id))
+ logger.info(f"Populated columns for table (ID: {table_item.id}")
- def _get_columns_for_table(self, table_item, req_options=None):
- url = "{0}/{1}/columns".format(self.baseurl, table_item.id)
+ def _get_columns_for_table(
+ self, table_item: TableItem, req_options: Optional[RequestOptions] = None
+ ) -> tuple[list[ColumnItem], PaginationItem]:
+ url = f"{self.baseurl}/{table_item.id}/columns"
server_response = self.get_request(url, req_options)
columns = ColumnItem.from_response(server_response.content, self.parent_srv.namespace)
pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace)
return columns, pagination_item
@api(version="3.5")
- def update_column(self, table_item, column_item):
- url = "{0}/{1}/columns/{2}".format(self.baseurl, table_item.id, column_item.id)
+ def update_column(self, table_item: TableItem, column_item: ColumnItem) -> ColumnItem:
+ """
+ Update the description of a column in a table.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#update_column
+
+ Parameters
+ ----------
+ table_item : TableItem
+ The TableItem object representing the table.
+
+ column_item : ColumnItem
+ The ColumnItem object representing the column to update.
+
+ Returns
+ -------
+ ColumnItem
+ The updated ColumnItem object.
+ """
+ url = f"{self.baseurl}/{table_item.id}/columns/{column_item.id}"
update_req = RequestFactory.Column.update_req(column_item)
server_response = self.put_request(url, update_req)
column = ColumnItem.from_response(server_response.content, self.parent_srv.namespace)[0]
- logger.info("Updated table item (ID: {0} & column item {1}".format(table_item.id, column_item.id))
+ logger.info(f"Updated table item (ID: {table_item.id} & column item {column_item.id}")
return column
@api(version="3.5")
- def populate_permissions(self, item):
+ def populate_permissions(self, item: TableItem) -> None:
self._permissions.populate(item)
@api(version="3.5")
- def update_permission(self, item, rules):
- import warnings
-
- warnings.warn(
- "Server.tables.update_permission is deprecated, " "please use Server.tables.update_permissions instead.",
- DeprecationWarning,
- )
- return self._permissions.update(item, rules)
-
- @api(version="3.5")
- def update_permissions(self, item, rules):
+ def update_permissions(self, item: TableItem, rules: list[PermissionsRule]) -> list[PermissionsRule]:
return self._permissions.update(item, rules)
@api(version="3.5")
- def delete_permission(self, item, rules):
+ def delete_permission(self, item: TableItem, rules: list[PermissionsRule]) -> None:
return self._permissions.delete(item, rules)
@api(version="3.5")
- def populate_dqw(self, item):
+ def populate_dqw(self, item: TableItem) -> None:
self._data_quality_warnings.populate(item)
@api(version="3.5")
- def update_dqw(self, item, warning):
+ def update_dqw(self, item: TableItem, warning: "DQWItem") -> list["DQWItem"]:
return self._data_quality_warnings.update(item, warning)
@api(version="3.5")
- def add_dqw(self, item, warning):
+ def add_dqw(self, item: TableItem, warning: "DQWItem") -> list["DQWItem"]:
return self._data_quality_warnings.add(item, warning)
@api(version="3.5")
- def delete_dqw(self, item):
+ def delete_dqw(self, item: TableItem) -> None:
self._data_quality_warnings.clear(item)
+
+ @api(version="3.9")
+ def add_tags(self, item: Union[TableItem, str], tags: Union[Iterable[str], str]) -> set[str]:
+ return super().add_tags(item, tags)
+
+ @api(version="3.9")
+ def delete_tags(self, item: Union[TableItem, str], tags: Union[Iterable[str], str]) -> None:
+ return super().delete_tags(item, tags)
+
+ def update_tags(self, item: TableItem) -> None: # type: ignore
+ raise NotImplementedError("Update tags is not implemented for TableItem")
diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py
index aaa5069c3..e1e95041d 100644
--- a/tableauserverclient/server/endpoint/tasks_endpoint.py
+++ b/tableauserverclient/server/endpoint/tasks_endpoint.py
@@ -1,35 +1,60 @@
-from .endpoint import Endpoint, api
-from .exceptions import MissingRequiredFieldError
-from .. import TaskItem, PaginationItem, RequestFactory
-
import logging
+from typing import Optional, TYPE_CHECKING
+
+from tableauserverclient.server.endpoint.endpoint import Endpoint, api
+from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError
+from tableauserverclient.models import TaskItem, PaginationItem
+from tableauserverclient.server import RequestFactory
+
+from tableauserverclient.helpers.logging import logger
-logger = logging.getLogger("tableau.endpoint.tasks")
+if TYPE_CHECKING:
+ from tableauserverclient.server.request_options import RequestOptions
class Tasks(Endpoint):
@property
- def baseurl(self):
- return "{0}/sites/{1}/tasks".format(self.parent_srv.baseurl, self.parent_srv.site_id)
+ def baseurl(self) -> str:
+ return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks"
- def __normalize_task_type(self, task_type):
+ def __normalize_task_type(self, task_type: str) -> str:
"""
The word for extract refresh used in API URL is "extractRefreshes".
It is different than the tag "extractRefresh" used in the request body.
"""
if task_type == TaskItem.Type.ExtractRefresh:
- return "{}es".format(task_type)
+ return f"{task_type}es"
else:
return task_type
@api(version="2.6")
- def get(self, req_options=None, task_type=TaskItem.Type.ExtractRefresh):
+ def get(
+ self, req_options: Optional["RequestOptions"] = None, task_type: str = TaskItem.Type.ExtractRefresh
+ ) -> tuple[list[TaskItem], PaginationItem]:
+ """
+ Returns information about tasks on the specified site.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#list_extract_refresh_tasks
+
+ Parameters
+ ----------
+ req_options : RequestOptions, optional
+ Options for the request, such as filtering, sorting, and pagination.
+
+ task_type : str, optional
+ The type of task to query. See TaskItem.Type for possible values.
+
+ Returns
+ -------
+ tuple[list[TaskItem], PaginationItem]
+
+ """
if task_type == TaskItem.Type.DataAcceleration:
- self.parent_srv.assert_at_least_version("3.8")
+ self.parent_srv.assert_at_least_version("3.8", "Data Acceleration Tasks")
- logger.info("Querying all {} tasks for the site".format(task_type))
+ logger.info("Querying all %s tasks for the site", task_type)
- url = "{0}/{1}".format(self.baseurl, self.__normalize_task_type(task_type))
+ url = f"{self.baseurl}/{self.__normalize_task_type(task_type)}"
server_response = self.get_request(url, req_options)
pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace)
@@ -37,11 +62,25 @@ def get(self, req_options=None, task_type=TaskItem.Type.ExtractRefresh):
return all_tasks, pagination_item
@api(version="2.6")
- def get_by_id(self, task_id):
+ def get_by_id(self, task_id: str) -> TaskItem:
+ """
+ Returns information about the specified task.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#get_extract_refresh_task
+
+ Parameters
+ ----------
+ task_id : str
+ The ID of the task to query.
+
+ Returns
+ -------
+ TaskItem
+ """
if not task_id:
error = "No Task ID provided"
raise ValueError(error)
- logger.info("Querying a single task by id ({})".format(task_id))
+ logger.info("Querying a single task by id %s", task_id)
url = "{}/{}/{}".format(
self.baseurl,
self.__normalize_task_type(TaskItem.Type.ExtractRefresh),
@@ -50,30 +89,87 @@ def get_by_id(self, task_id):
server_response = self.get_request(url)
return TaskItem.from_response(server_response.content, self.parent_srv.namespace)[0]
+ @api(version="3.19")
+ def create(self, extract_item: TaskItem) -> TaskItem:
+ """
+ Creates a custom schedule for an extract refresh on Tableau Cloud. For
+ Tableau Server, use the Schedules endpoint to create a schedule.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#create_cloud_extract_refresh_task
+
+ Parameters
+ ----------
+ extract_item : TaskItem
+ The extract refresh task to create.
+
+ Returns
+ -------
+ TaskItem
+ """
+ if not extract_item:
+ error = "No extract refresh provided"
+ raise ValueError(error)
+ logger.info("Creating an extract refresh %s", extract_item)
+ url = f"{self.baseurl}/{self.__normalize_task_type(TaskItem.Type.ExtractRefresh)}"
+ create_req = RequestFactory.Task.create_extract_req(extract_item)
+ server_response = self.post_request(url, create_req)
+ return server_response.content
+
@api(version="2.6")
- def run(self, task_item):
+ def run(self, task_item: TaskItem) -> bytes:
+ """
+ Runs the specified extract refresh task.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#run_extract_refresh_task
+
+ Parameters
+ ----------
+ task_item : TaskItem
+ The task to run.
+
+ Returns
+ -------
+ bytes
+ """
if not task_item.id:
- error = "User item missing ID."
+ error = "Task item missing ID."
raise MissingRequiredFieldError(error)
- url = "{0}/{1}/{2}/runNow".format(
+ url = "{}/{}/{}/runNow".format(
self.baseurl,
self.__normalize_task_type(TaskItem.Type.ExtractRefresh),
task_item.id,
)
run_req = RequestFactory.Task.run_req(task_item)
server_response = self.post_request(url, run_req)
- return server_response.content
+ return server_response.content # Todo add typing
# Delete 1 task by id
@api(version="3.6")
- def delete(self, task_id, task_type=TaskItem.Type.ExtractRefresh):
+ def delete(self, task_id: str, task_type: str = TaskItem.Type.ExtractRefresh) -> None:
+ """
+ Deletes the specified extract refresh task on Tableau Server or Tableau Cloud.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#delete_extract_refresh_task
+
+ Parameters
+ ----------
+ task_id : str
+ The ID of the task to delete.
+
+ task_type : str, default TaskItem.Type.ExtractRefresh
+ The type of task to query. See TaskItem.Type for possible values.
+
+ Returns
+ -------
+ None
+ """
if task_type == TaskItem.Type.DataAcceleration:
- self.parent_srv.assert_at_least_version("3.8")
+ self.parent_srv.assert_at_least_version("3.8", "Data Acceleration Tasks")
if not task_id:
error = "No Task ID provided"
raise ValueError(error)
- url = "{0}/{1}/{2}".format(self.baseurl, self.__normalize_task_type(task_type), task_id)
+ url = f"{self.baseurl}/{self.__normalize_task_type(task_type)}/{task_id}"
self.delete_request(url)
- logger.info("Deleted single task (ID: {0})".format(task_id))
+ logger.info("Deleted single task (ID: %s)", task_id)
diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py
index 6adbf92fb..17af21a03 100644
--- a/tableauserverclient/server/endpoint/users_endpoint.py
+++ b/tableauserverclient/server/endpoint/users_endpoint.py
@@ -1,34 +1,93 @@
+import copy
+import logging
+from typing import Optional
+
+from tableauserverclient.server.query import QuerySet
+
from .endpoint import QuerysetEndpoint, api
-from .exceptions import MissingRequiredFieldError
-from .. import (
- RequestFactory,
- RequestOptions,
- UserItem,
- WorkbookItem,
- PaginationItem,
- GroupItem,
-)
+from .exceptions import MissingRequiredFieldError, ServerResponseError
+from tableauserverclient.server import RequestFactory, RequestOptions
+from tableauserverclient.models import UserItem, WorkbookItem, PaginationItem, GroupItem
from ..pager import Pager
-import copy
-import logging
+from tableauserverclient.helpers.logging import logger
-logger = logging.getLogger("tableau.endpoint.users")
+class Users(QuerysetEndpoint[UserItem]):
+ """
+ The user resources for Tableau Server are defined in the UserItem class.
+ The class corresponds to the user resources you can access using the
+ Tableau Server REST API. The user methods are based upon the endpoints for
+ users in the REST API and operate on the UserItem class. Only server and
+ site administrators can access the user resources.
+ """
-class Users(QuerysetEndpoint):
@property
- def baseurl(self):
- return "{0}/sites/{1}/users".format(self.parent_srv.baseurl, self.parent_srv.site_id)
+ def baseurl(self) -> str:
+ return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/users"
# Gets all users
@api(version="2.0")
- def get(self, req_options=None):
+ def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[UserItem], PaginationItem]:
+ """
+ Query all users on the site. Request is paginated and returns a subset of users.
+ By default, the request returns the first 100 users on the site.
+
+ Parameters
+ ----------
+ req_options : Optional[RequestOptions]
+ Optional request options to filter and sort the results.
+
+ Returns
+ -------
+ tuple[list[UserItem], PaginationItem]
+ Returns a tuple with a list of UserItem objects and a PaginationItem object.
+
+ Raises
+ ------
+ ServerResponseError
+ code: 400006
+ summary: Invalid page number
+ detail: The page number is not an integer, is less than one, or is
+ greater than the final page number for users at the requested
+ page size.
+
+ ServerResponseError
+ code: 400007
+ summary: Invalid page size
+ detail: The page size parameter is not an integer, is less than one.
+
+ ServerResponseError
+ code: 403014
+ summary: Page size limit exceeded
+ detail: The specified page size is larger than the maximum page size
+
+ ServerResponseError
+ code: 404000
+ summary: Site not found
+ detail: The site ID in the URI doesn't correspond to an existing site.
+
+ ServerResponseError
+ code: 405000
+ summary: Invalid request method
+ detail: Request type was not GET.
+
+ Examples
+ --------
+ >>> import tableauserverclient as TSC
+ >>> tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD')
+ >>> server = TSC.Server('https://SERVERURL')
+
+ >>> with server.auth.sign_in(tableau_auth):
+ >>> users_page, pagination_item = server.users.get()
+ >>> print("\nThere are {} user on site: ".format(pagination_item.total_available))
+ >>> print([user.name for user in users_page])
+ """
logger.info("Querying all users on site")
if req_options is None:
req_options = RequestOptions()
- req_options._all_fields = True
+ req_options.all_fields = True
url = self.baseurl
server_response = self.get_request(url, req_options)
@@ -38,75 +97,420 @@ 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:
+ """
+ Query a single user by ID.
+
+ Parameters
+ ----------
+ user_id : str
+ The ID of the user to query.
+
+ Returns
+ -------
+ UserItem
+ The user item that was queried.
+
+ Raises
+ ------
+ ValueError
+ If the user ID is not specified.
+
+ ServerResponseError
+ code: 404000
+ summary: Site not found
+ detail: The site ID in the URI doesn't correspond to an existing site.
+
+ ServerResponseError
+ code: 403133
+ summary: Query user permissions forbidden
+ detail: The user does not have permissions to query user information
+ for other users
+
+ ServerResponseError
+ code: 404002
+ summary: User not found
+ detail: The user ID in the URI doesn't correspond to an existing user.
+
+ ServerResponseError
+ code: 405000
+ summary: Invalid request method
+ detail: Request type was not GET.
+
+ Examples
+ --------
+ >>> user1 = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d')
+ """
if not user_id:
error = "User ID undefined."
raise ValueError(error)
- logger.info("Querying single user (ID: {0})".format(user_id))
- url = "{0}/{1}".format(self.baseurl, user_id)
+ logger.info(f"Querying single user (ID: {user_id})")
+ url = f"{self.baseurl}/{user_id}"
server_response = self.get_request(url)
return UserItem.from_response(server_response.content, self.parent_srv.namespace).pop()
# Update user
@api(version="2.0")
- def update(self, user_item, password=None):
+ def update(self, user_item: UserItem, password: Optional[str] = None) -> UserItem:
+ """
+ Modifies information about the specified user.
+
+ If Tableau Server is configured to use local authentication, you can
+ update the user's name, email address, password, or site role.
+
+ If Tableau Server is configured to use Active Directory
+ authentication, you can change the user's display name (full name),
+ email address, and site role. However, if you synchronize the user with
+ Active Directory, the display name and email address will be
+ overwritten with the information that's in Active Directory.
+
+ For Tableau Cloud, you can update the site role for a user, but you
+ cannot update or change a user's password, user name (email address),
+ or full name.
+
+ Parameters
+ ----------
+ user_item : UserItem
+ The user item to update.
+
+ password : Optional[str]
+ The new password for the user.
+
+ Returns
+ -------
+ UserItem
+ The user item that was updated.
+
+ Raises
+ ------
+ MissingRequiredFieldError
+ If the user item is missing an ID.
+
+ Examples
+ --------
+ >>> user = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d')
+ >>> user.fullname = 'New Full Name'
+ >>> updated_user = server.users.update(user)
+
+ """
if not user_item.id:
error = "User item missing ID."
raise MissingRequiredFieldError(error)
- url = "{0}/{1}".format(self.baseurl, user_item.id)
+ url = f"{self.baseurl}/{user_item.id}"
update_req = RequestFactory.User.update_req(user_item, password)
server_response = self.put_request(url, update_req)
- logger.info("Updated user item (ID: {0})".format(user_item.id))
+ logger.info(f"Updated user item (ID: {user_item.id})")
updated_item = copy.copy(user_item)
return updated_item._parse_common_tags(server_response.content, self.parent_srv.namespace)
# Delete 1 user by id
@api(version="2.0")
- def remove(self, user_id):
+ def remove(self, user_id: str, map_assets_to: Optional[str] = None) -> None:
+ """
+ Removes a user from the site. You can also specify a user to map the
+ assets to when you remove the user.
+
+ Parameters
+ ----------
+ user_id : str
+ The ID of the user to remove.
+
+ map_assets_to : Optional[str]
+ The ID of the user to map the assets to when you remove the user.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ ValueError
+ If the user ID is not specified.
+
+ Examples
+ --------
+ >>> server.users.remove('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d')
+ """
if not user_id:
error = "User ID undefined."
raise ValueError(error)
- url = "{0}/{1}".format(self.baseurl, user_id)
+ url = f"{self.baseurl}/{user_id}"
+ if map_assets_to is not None:
+ url += f"?mapAssetsTo={map_assets_to}"
self.delete_request(url)
- logger.info("Removed single user (ID: {0})".format(user_id))
+ logger.info(f"Removed single user (ID: {user_id})")
# Add new user to site
@api(version="2.0")
- def add(self, user_item):
+ def add(self, user_item: UserItem) -> UserItem:
+ """
+ Adds the user to the site.
+
+ To add a new user to the site you need to first create a new user_item
+ (from UserItem class). When you create a new user, you specify the name
+ of the user and their site role. For Tableau Cloud, you also specify
+ the auth_setting attribute in your request. When you add user to
+ Tableau Cloud, the name of the user must be the email address that is
+ used to sign in to Tableau Cloud. After you add a user, Tableau Cloud
+ sends the user an email invitation. The user can click the link in the
+ invitation to sign in and update their full name and password.
+
+ Parameters
+ ----------
+ user_item : UserItem
+ The user item to add to the site.
+
+ Returns
+ -------
+ UserItem
+ The user item that was added to the site with attributes from the
+ site populated.
+
+ Raises
+ ------
+ ValueError
+ If the user item is missing a name
+
+ ValueError
+ If the user item is missing a site role
+
+ ServerResponseError
+ code: 400000
+ summary: Bad Request
+ detail: The content of the request body is missing or incomplete, or
+ contains malformed XML.
+
+ ServerResponseError
+ code: 400003
+ summary: Bad Request
+ detail: The user authentication setting ServerDefault is not
+ supported for you site. Try again using TableauIDWithMFA instead.
+
+ ServerResponseError
+ code: 400013
+ summary: Invalid site role
+ detail: The value of the siteRole attribute must be Explorer,
+ ExplorerCanPublish, SiteAdministratorCreator,
+ SiteAdministratorExplorer, Unlicensed, or Viewer.
+
+ ServerResponseError
+ code: 404000
+ summary: Site not found
+ detail: The site ID in the URI doesn't correspond to an existing site.
+
+ ServerResponseError
+ code: 404002
+ summary: User not found
+ detail: The server is configured to use Active Directory for
+ authentication, and the username specified in the request body
+ doesn't match an existing user in Active Directory.
+
+ ServerResponseError
+ code: 405000
+ summary: Invalid request method
+ detail: Request type was not POST.
+
+ ServerResponseError
+ code: 409000
+ summary: User conflict
+ detail: The specified user already exists on the site.
+
+ ServerResponseError
+ code: 409005
+ summary: Guest user conflict
+ detail: The Tableau Server API doesn't allow adding a user with the
+ guest role to a site.
+
+
+ Examples
+ --------
+ >>> import tableauserverclient as TSC
+ >>> server = TSC.Server('https://SERVERURL')
+ >>> # Login to the server
+
+ >>> new_user = TSC.UserItem(name='new_user', site_role=TSC.UserItem.Role.Unlicensed)
+ >>> new_user = server.users.add(new_user)
+
+ """
url = self.baseurl
+ logger.info(f"Add user {user_item.name}")
add_req = RequestFactory.User.add_req(user_item)
server_response = self.post_request(url, add_req)
+ logger.info(server_response)
new_user = UserItem.from_response(server_response.content, self.parent_srv.namespace).pop()
- logger.info("Added new user (ID: {0})".format(new_user.id))
+ logger.info(f"Added new user (ID: {new_user.id})")
return new_user
+ # Add new users to site. This does not actually perform a bulk action, it's syntactic sugar
+ @api(version="2.0")
+ def add_all(self, users: list[UserItem]):
+ created = []
+ failed = []
+ for user in users:
+ try:
+ result = self.add(user)
+ created.append(result)
+ except Exception as e:
+ failed.append(user)
+ return created, failed
+
+ # helping the user by parsing a file they could have used to add users through the UI
+ # line format: Username [required], password, display name, license, admin, publish
+ @api(version="2.0")
+ def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[UserItem, ServerResponseError]]]:
+ created = []
+ failed = []
+ if not filepath.find("csv"):
+ raise ValueError("Only csv files are accepted")
+
+ with open(filepath) as csv_file:
+ csv_file.seek(0) # set to start of file in case it has been read earlier
+ line: str = csv_file.readline()
+ while line and line != "":
+ user: UserItem = UserItem.CSVImport.create_user_from_line(line)
+ try:
+ print(user)
+ result = self.add(user)
+ created.append(result)
+ except ServerResponseError as serverError:
+ print("failed")
+ failed.append((user, serverError))
+ line = csv_file.readline()
+ return created, failed
+
# Get workbooks for user
@api(version="2.0")
- def populate_workbooks(self, user_item, req_options=None):
+ def populate_workbooks(
+ self, user_item: UserItem, req_options: Optional[RequestOptions] = None, owned_only: bool = False
+ ) -> None:
+ """
+ Returns information about the workbooks that the specified user owns
+ or has Read (view) permissions for. If owned_only is set to True,
+ only the workbooks that the user owns are returned. If owned_only is
+ set to False, all workbooks that the user has Read (view) permissions
+ for are returned.
+
+ This method retrieves the workbook information for the specified user.
+ The REST API is designed to return only the information you ask for
+ explicitly. When you query for all the users, the workbook information
+ for each user is not included. Use this method to retrieve information
+ about the workbooks that the user owns or has Read (view) permissions.
+ The method adds the list of workbooks to the user item object
+ (user_item.workbooks).
+
+ Parameters
+ ----------
+ user_item : UserItem
+ The user item to populate workbooks for.
+
+ req_options : Optional[RequestOptions]
+ Optional request options to filter and sort the results.
+
+ owned_only : bool, default=False
+ If True, only the workbooks that the user owns are returned.
+ If False, all workbooks that the user has Read (view) permissions
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ MissingRequiredFieldError
+ If the user item is missing an ID.
+
+ Examples
+ --------
+ >>> user = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d')
+ >>> server.users.populate_workbooks(user)
+ >>> for wb in user.workbooks:
+ >>> print(wb.name)
+ """
if not user_item.id:
error = "User item missing ID."
raise MissingRequiredFieldError(error)
def wb_pager():
- return Pager(lambda options: self._get_wbs_for_user(user_item, options), req_options)
+ def func(req_options):
+ return self._get_wbs_for_user(user_item, req_options, owned_only=owned_only)
+
+ return Pager(func, req_options)
user_item._set_workbooks(wb_pager)
- def _get_wbs_for_user(self, user_item, req_options=None):
- url = "{0}/{1}/workbooks".format(self.baseurl, user_item.id)
+ def _get_wbs_for_user(
+ self,
+ user_item: UserItem,
+ req_options: Optional[RequestOptions] = None,
+ owned_only: bool = False,
+ ) -> tuple[list[WorkbookItem], PaginationItem]:
+ url = f"{self.baseurl}/{user_item.id}/workbooks"
+ if owned_only:
+ url += "?ownedBy=true"
server_response = self.get_request(url, req_options)
- logger.info("Populated workbooks for user (ID: {0})".format(user_item.id))
+ logger.info(f"Populated workbooks for user (ID: {user_item.id})")
workbook_item = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)
pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace)
return workbook_item, pagination_item
- def populate_favorites(self, user_item):
+ def populate_favorites(self, user_item: UserItem) -> None:
+ """
+ Populate the favorites for the user.
+
+ Parameters
+ ----------
+ user_item : UserItem
+ The user item to populate favorites for.
+
+ Returns
+ -------
+ None
+
+ Examples
+ --------
+ >>> import tableauserverclient as TSC
+ >>> server = TSC.Server('https://SERVERURL')
+ >>> # Login to the server
+
+ >>> user = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d')
+ >>> server.users.populate_favorites(user)
+ >>> for obj_type, items in user.favorites.items():
+ >>> print(f"Favorites for {obj_type}:")
+ >>> for item in items:
+ >>> print(item.name)
+ """
self.parent_srv.favorites.get(user_item)
# Get groups for user
@api(version="3.7")
- def populate_groups(self, user_item, req_options=None):
+ def populate_groups(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None:
+ """
+ Populate the groups for the user.
+
+ Parameters
+ ----------
+ user_item : UserItem
+ The user item to populate groups for.
+
+ req_options : Optional[RequestOptions]
+ Optional request options to filter and sort the results.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ MissingRequiredFieldError
+ If the user item is missing an ID.
+
+ Examples
+ --------
+ >>> server.users.populate_groups(user)
+ >>> for group in user.groups:
+ >>> print(group.name)
+ """
if not user_item.id:
error = "User item missing ID."
raise MissingRequiredFieldError(error)
@@ -119,10 +523,49 @@ def groups_for_user_pager():
user_item._set_groups(groups_for_user_pager)
- def _get_groups_for_user(self, user_item, req_options=None):
- url = "{0}/{1}/groups".format(self.baseurl, user_item.id)
+ def _get_groups_for_user(
+ self, user_item: UserItem, req_options: Optional[RequestOptions] = None
+ ) -> tuple[list[GroupItem], PaginationItem]:
+ url = f"{self.baseurl}/{user_item.id}/groups"
server_response = self.get_request(url, req_options)
- logger.info("Populated groups for user (ID: {0})".format(user_item.id))
+ logger.info(f"Populated groups for user (ID: {user_item.id})")
group_item = GroupItem.from_response(server_response.content, self.parent_srv.namespace)
pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace)
return group_item, pagination_item
+
+ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[UserItem]:
+ """
+ Queries the Tableau Server for items using the specified filters. Page
+ size can be specified to limit the number of items returned in a single
+ request. If not specified, the default page size is 100. Page size can
+ be an integer between 1 and 1000.
+
+ No positional arguments are allowed. All filters must be specified as
+ keyword arguments. If you use the equality operator, you can specify it
+ through =. If you want to use a different operator,
+ you can specify it through __=. Field
+ names can either be in snake_case or camelCase.
+
+ This endpoint supports the following fields and operators:
+
+
+ domain_name=...
+ domain_name__in=...
+ friendly_name=...
+ friendly_name__in=...
+ is_local=...
+ last_login=...
+ last_login__gt=...
+ last_login__gte=...
+ last_login__lt=...
+ last_login__lte=...
+ luid=...
+ luid__in=...
+ name__cieq=...
+ name=...
+ name__in=...
+ site_role=...
+ site_role__in=...
+ """
+
+ return super().filter(*invalid, page_size=page_size, **kwargs)
diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py
index a00e7f145..9d1c8b00f 100644
--- a/tableauserverclient/server/endpoint/views_endpoint.py
+++ b/tableauserverclient/server/endpoint/views_endpoint.py
@@ -1,32 +1,72 @@
-from .endpoint import QuerysetEndpoint, api
-from .exceptions import MissingRequiredFieldError
-from .resource_tagger import _ResourceTagger
-from .permissions_endpoint import _PermissionsEndpoint
-from .. import ViewItem, PaginationItem
-
-from contextlib import closing
import logging
+from contextlib import closing
+
+from tableauserverclient.models.permissions_item import PermissionsRule
+from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api
+from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError, UnsupportedAttributeError
+from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint
+from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin
+from tableauserverclient.server.query import QuerySet
+
+from tableauserverclient.models import ViewItem, PaginationItem
+
+from tableauserverclient.helpers.logging import logger
-logger = logging.getLogger("tableau.endpoint.views")
+from typing import Optional, TYPE_CHECKING, Union
+from collections.abc import Iterable, Iterator
+if TYPE_CHECKING:
+ from tableauserverclient.server.request_options import (
+ RequestOptions,
+ CSVRequestOptions,
+ PDFRequestOptions,
+ ImageRequestOptions,
+ ExcelRequestOptions,
+ )
+
+
+class Views(QuerysetEndpoint[ViewItem], TaggingMixin[ViewItem]):
+ """
+ The Tableau Server Client provides methods for interacting with view
+ resources, or endpoints. These methods correspond to the endpoints for views
+ in the Tableau Server REST API.
+ """
-class Views(QuerysetEndpoint):
def __init__(self, parent_srv):
- super(Views, self).__init__(parent_srv)
- self._resource_tagger = _ResourceTagger(parent_srv)
+ super().__init__(parent_srv)
self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl)
# Used because populate_preview_image functionaliy requires workbook endpoint
@property
- def siteurl(self):
- return "{0}/sites/{1}".format(self.parent_srv.baseurl, self.parent_srv.site_id)
+ def siteurl(self) -> str:
+ return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}"
@property
- def baseurl(self):
- return "{0}/views".format(self.siteurl)
+ def baseurl(self) -> str:
+ return f"{self.siteurl}/views"
@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]:
+ """
+ Returns the list of views on the site. Paginated endpoint.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#query_views_for_site
+
+ Parameters
+ ----------
+ req_options: Optional[RequestOptions], default None
+ The request options for the request. These options can include
+ parameters such as page size and sorting.
+
+ usage: bool, default False
+ If True, includes usage statistics in the response.
+
+ Returns
+ -------
+ views: tuple[list[ViewItem], PaginationItem]
+ """
logger.info("Querying all views on site")
url = self.baseurl
if usage:
@@ -37,17 +77,54 @@ 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, usage: bool = False) -> ViewItem:
+ """
+ Returns the details of a specific view.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#get_view
+
+ Parameters
+ ----------
+ view_id: str
+ The view ID.
+
+ usage: bool, default False
+ If True, includes usage statistics in the response.
+
+ Returns
+ -------
+ view_item: ViewItem
+ """
if not view_id:
error = "View item missing ID."
raise MissingRequiredFieldError(error)
- logger.info("Querying single view (ID: {0})".format(view_id))
- url = "{0}/{1}".format(self.baseurl, view_id)
+ logger.info(f"Querying single view (ID: {view_id})")
+ url = f"{self.baseurl}/{view_id}"
+ if usage:
+ url += "?includeUsageStatistics=true"
server_response = self.get_request(url)
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:
+ """
+ Populates a preview image for the specified view.
+
+ This method gets the preview image (thumbnail) for the specified view
+ item. The method uses the id and workbook_id fields to query the preview
+ image. The method populates the preview_image for the view.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#query_view_with_preview
+
+ Parameters
+ ----------
+ view_item: ViewItem
+ The view item for which to populate the preview image.
+
+ Returns
+ -------
+ None
+ """
if not view_item.id or not view_item.workbook_id:
error = "View item missing ID or workbook ID."
raise MissingRequiredFieldError(error)
@@ -56,16 +133,37 @@ def image_fetcher():
return self._get_preview_for_view(view_item)
view_item._set_preview_image(image_fetcher)
- logger.info("Populated preview image for view (ID: {0})".format(view_item.id))
+ logger.info(f"Populated preview image for view (ID: {view_item.id})")
- def _get_preview_for_view(self, view_item):
- url = "{0}/workbooks/{1}/views/{2}/previewImage".format(self.siteurl, view_item.workbook_id, view_item.id)
+ def _get_preview_for_view(self, view_item: ViewItem) -> bytes:
+ url = f"{self.siteurl}/workbooks/{view_item.workbook_id}/views/{view_item.id}/previewImage"
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:
+ """
+ Populates the image of the specified view.
+
+ This method uses the id field to query the image, and populates the
+ image content as the image field.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#query_view_image
+
+ Parameters
+ ----------
+ view_item: ViewItem
+ The view item for which to populate the image.
+
+ req_options: Optional[ImageRequestOptions], default None
+ Optional request options for the request. These options can include
+ parameters such as image resolution and max age.
+
+ Returns
+ -------
+ None
+ """
if not view_item.id:
error = "View item missing ID."
raise MissingRequiredFieldError(error)
@@ -73,17 +171,41 @@ def populate_image(self, view_item, req_options=None):
def image_fetcher():
return self._get_view_image(view_item, req_options)
+ if not self.parent_srv.check_at_least_version("3.23") and req_options is not None:
+ if req_options.viz_height or req_options.viz_width:
+ raise UnsupportedAttributeError("viz_height and viz_width are only supported in 3.23+")
+
view_item._set_image(image_fetcher)
- logger.info("Populated image for view (ID: {0})".format(view_item.id))
+ logger.info(f"Populated image for view (ID: {view_item.id})")
- def _get_view_image(self, view_item, req_options):
- url = "{0}/{1}/image".format(self.baseurl, view_item.id)
+ def _get_view_image(self, view_item: ViewItem, req_options: Optional["ImageRequestOptions"]) -> bytes:
+ url = f"{self.baseurl}/{view_item.id}/image"
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:
+ """
+ Populates the PDF content of the specified view.
+
+ This method populates a PDF with image(s) of the view you specify.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#query_view_pdf
+
+ Parameters
+ ----------
+ view_item: ViewItem
+ The view item for which to populate the PDF.
+
+ req_options: Optional[PDFRequestOptions], default None
+ Optional request options for the request. These options can include
+ parameters such as orientation and paper size.
+
+ Returns
+ -------
+ None
+ """
if not view_item.id:
error = "View item missing ID."
raise MissingRequiredFieldError(error)
@@ -92,16 +214,37 @@ def pdf_fetcher():
return self._get_view_pdf(view_item, req_options)
view_item._set_pdf(pdf_fetcher)
- logger.info("Populated pdf for view (ID: {0})".format(view_item.id))
+ logger.info(f"Populated pdf for view (ID: {view_item.id})")
- def _get_view_pdf(self, view_item, req_options):
- url = "{0}/{1}/pdf".format(self.baseurl, view_item.id)
+ def _get_view_pdf(self, view_item: ViewItem, req_options: Optional["PDFRequestOptions"]) -> bytes:
+ url = f"{self.baseurl}/{view_item.id}/pdf"
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:
+ """
+ Populates the CSV data of the specified view.
+
+ This method uses the id field to query the CSV data, and populates the
+ data as the csv field.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#query_view_data
+
+ Parameters
+ ----------
+ view_item: ViewItem
+ The view item for which to populate the CSV data.
+
+ req_options: Optional[CSVRequestOptions], default None
+ Optional request options for the request. These options can include
+ parameters such as view filters and max age.
+
+ Returns
+ -------
+ None
+ """
if not view_item.id:
error = "View item missing ID."
raise MissingRequiredFieldError(error)
@@ -110,34 +253,254 @@ def csv_fetcher():
return self._get_view_csv(view_item, req_options)
view_item._set_csv(csv_fetcher)
- logger.info("Populated csv for view (ID: {0})".format(view_item.id))
+ logger.info(f"Populated csv for view (ID: {view_item.id})")
- def _get_view_csv(self, view_item, req_options):
- url = "{0}/{1}/data".format(self.baseurl, view_item.id)
+ def _get_view_csv(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"]) -> Iterator[bytes]:
+ url = f"{self.baseurl}/{view_item.id}/data"
with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response:
- csv = server_response.iter_content(1024)
- return csv
+ yield from server_response.iter_content(1024)
+
+ @api(version="3.8")
+ def populate_excel(self, view_item: ViewItem, req_options: Optional["ExcelRequestOptions"] = None) -> None:
+ """
+ Populates the Excel data of the specified view.
+
+ This method uses the id field to query the Excel data, and populates the
+ data as the Excel field.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#download_view_excel
+
+ Parameters
+ ----------
+ view_item: ViewItem
+ The view item for which to populate the Excel data.
+
+ req_options: Optional[ExcelRequestOptions], default None
+ Optional request options for the request. These options can include
+ parameters such as view filters and max age.
+
+ Returns
+ -------
+ None
+ """
+ if not view_item.id:
+ error = "View item missing ID."
+ raise MissingRequiredFieldError(error)
+
+ def excel_fetcher():
+ return self._get_view_excel(view_item, req_options)
+
+ view_item._set_excel(excel_fetcher)
+ logger.info(f"Populated excel for view (ID: {view_item.id})")
+
+ def _get_view_excel(self, view_item: ViewItem, req_options: Optional["ExcelRequestOptions"]) -> Iterator[bytes]:
+ url = f"{self.baseurl}/{view_item.id}/crosstab/excel"
+
+ with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response:
+ yield from server_response.iter_content(1024)
@api(version="3.2")
- def populate_permissions(self, item):
+ def populate_permissions(self, item: ViewItem) -> None:
+ """
+ Returns a list of permissions for the specific view.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_view_permissions
+
+ Parameters
+ ----------
+ item: ViewItem
+ The view item for which to populate the permissions.
+
+ Returns
+ -------
+ None
+ """
self._permissions.populate(item)
@api(version="3.2")
- def update_permissions(self, resource, rules):
+ def update_permissions(self, resource: ViewItem, rules: list[PermissionsRule]) -> list[PermissionsRule]:
+ """ """
return self._permissions.update(resource, rules)
@api(version="3.2")
- def delete_permission(self, item, capability_item):
+ def delete_permission(self, item: ViewItem, capability_item: PermissionsRule) -> None:
+ """
+ Deletes permission to the specified view (also known as a sheet) for a
+ Tableau Server user or group.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_view_permission
+
+ Parameters
+ ----------
+ item: ViewItem
+ The view item for which to delete the permission.
+
+ capability_item: PermissionsRule
+ The permission rule to delete.
+
+ Returns
+ -------
+ None
+ """
return self._permissions.delete(item, capability_item)
# Update view. Currently only tags can be updated
- def update(self, view_item):
+ def update(self, view_item: ViewItem) -> ViewItem:
+ """
+ Updates the tags for the specified view. All other fields are managed
+ through the WorkbookItem object.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#add_tags_to_view
+
+ Parameters
+ ----------
+ view_item: ViewItem
+ The view item for which to update tags.
+
+ Returns
+ -------
+ ViewItem
+ """
if not view_item.id:
error = "View item missing ID. View must be retrieved from server first."
raise MissingRequiredFieldError(error)
- self._resource_tagger.update_tags(self.baseurl, view_item)
+ self.update_tags(view_item)
# Returning view item to stay consistent with datasource/view update functions
return view_item
+
+ @api(version="1.0")
+ def add_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) -> set[str]:
+ """
+ Adds tags to the specified view.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#add_tags_to_view
+
+ Parameters
+ ----------
+ item: Union[ViewItem, str]
+ The view item or view ID to which to add tags.
+
+ tags: Union[Iterable[str], str]
+ The tags to add to the view.
+
+ Returns
+ -------
+ set[str]
+
+ """
+ return super().add_tags(item, tags)
+
+ @api(version="1.0")
+ def delete_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) -> None:
+ """
+ Deletes tags from the specified view.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#delete_tags_from_view
+
+ Parameters
+ ----------
+ item: Union[ViewItem, str]
+ The view item or view ID from which to delete tags.
+
+ tags: Union[Iterable[str], str]
+ The tags to delete from the view.
+
+ Returns
+ -------
+ None
+ """
+ return super().delete_tags(item, tags)
+
+ @api(version="1.0")
+ def update_tags(self, item: ViewItem) -> None:
+ """
+ Updates the tags for the specified view. Any changes to the tags must
+ be made by editing the tags attribute of the view item.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#add_tags_to_view
+
+ Parameters
+ ----------
+ item: ViewItem
+ The view item for which to update tags.
+
+ Returns
+ -------
+ None
+ """
+ return super().update_tags(item)
+
+ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[ViewItem]:
+ """
+ Queries the Tableau Server for items using the specified filters. Page
+ size can be specified to limit the number of items returned in a single
+ request. If not specified, the default page size is 100. Page size can
+ be an integer between 1 and 1000.
+
+ No positional arguments are allowed. All filters must be specified as
+ keyword arguments. If you use the equality operator, you can specify it
+ through =. If you want to use a different operator,
+ you can specify it through __=. Field
+ names can either be in snake_case or camelCase.
+
+ This endpoint supports the following fields and operators:
+
+
+ caption=...
+ caption__in=...
+ content_url=...
+ content_url__in=...
+ created_at=...
+ created_at__gt=...
+ created_at__gte=...
+ created_at__lt=...
+ created_at__lte=...
+ favorites_total=...
+ favorites_total__gt=...
+ favorites_total__gte=...
+ favorites_total__lt=...
+ favorites_total__lte=...
+ fields=...
+ fields__in=...
+ hits_total=...
+ hits_total__gt=...
+ hits_total__gte=...
+ hits_total__lt=...
+ hits_total__lte=...
+ name=...
+ name__in=...
+ owner_domain=...
+ owner_domain__in=...
+ owner_email=...
+ owner_email__in=...
+ owner_name=...
+ project_name=...
+ project_name__in=...
+ sheet_number=...
+ sheet_number__gt=...
+ sheet_number__gte=...
+ sheet_number__lt=...
+ sheet_number__lte=...
+ sheet_type=...
+ sheet_type__in=...
+ tags=...
+ tags__in=...
+ title=...
+ title__in=...
+ updated_at=...
+ updated_at__gt=...
+ updated_at__gte=...
+ updated_at__lt=...
+ updated_at__lte=...
+ view_url_name=...
+ view_url_name__in=...
+ workbook_description=...
+ workbook_description__in=...
+ workbook_name=...
+ workbook_name__in=...
+ """
+
+ return super().filter(*invalid, page_size=page_size, **kwargs)
diff --git a/tableauserverclient/server/endpoint/virtual_connections_endpoint.py b/tableauserverclient/server/endpoint/virtual_connections_endpoint.py
new file mode 100644
index 000000000..944b72502
--- /dev/null
+++ b/tableauserverclient/server/endpoint/virtual_connections_endpoint.py
@@ -0,0 +1,174 @@
+from functools import partial
+import json
+from pathlib import Path
+from typing import Optional, TYPE_CHECKING, Union
+from collections.abc import Iterable
+
+from tableauserverclient.models.connection_item import ConnectionItem
+from tableauserverclient.models.pagination_item import PaginationItem
+from tableauserverclient.models.revision_item import RevisionItem
+from tableauserverclient.models.virtual_connection_item import VirtualConnectionItem
+from tableauserverclient.server.request_factory import RequestFactory
+from tableauserverclient.server.request_options import RequestOptions
+from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api
+from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint
+from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin
+from tableauserverclient.server.pager import Pager
+
+if TYPE_CHECKING:
+ from tableauserverclient.server import Server
+
+
+class VirtualConnections(QuerysetEndpoint[VirtualConnectionItem], TaggingMixin):
+ def __init__(self, parent_srv: "Server") -> None:
+ super().__init__(parent_srv)
+ self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl)
+
+ @property
+ def baseurl(self) -> str:
+ return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/virtualConnections"
+
+ @api(version="3.18")
+ def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[VirtualConnectionItem], PaginationItem]:
+ server_response = self.get_request(self.baseurl, req_options)
+ pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace)
+ virtual_connections = VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace)
+ return virtual_connections, pagination_item
+
+ @api(version="3.18")
+ def populate_connections(self, virtual_connection: VirtualConnectionItem) -> VirtualConnectionItem:
+ def _connection_fetcher():
+ return Pager(partial(self._get_virtual_database_connections, virtual_connection))
+
+ virtual_connection._connections = _connection_fetcher
+ return virtual_connection
+
+ def _get_virtual_database_connections(
+ self, virtual_connection: VirtualConnectionItem, req_options: Optional[RequestOptions] = None
+ ) -> tuple[list[ConnectionItem], PaginationItem]:
+ server_response = self.get_request(f"{self.baseurl}/{virtual_connection.id}/connections", req_options)
+ connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)
+ pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace)
+
+ return connections, pagination_item
+
+ @api(version="3.18")
+ def update_connection_db_connection(
+ self, virtual_connection: Union[str, VirtualConnectionItem], connection: ConnectionItem
+ ) -> ConnectionItem:
+ vconn_id = getattr(virtual_connection, "id", virtual_connection)
+ url = f"{self.baseurl}/{vconn_id}/connections/{connection.id}/modify"
+ xml_request = RequestFactory.VirtualConnection.update_db_connection(connection)
+ server_response = self.put_request(url, xml_request)
+ return ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0]
+
+ @api(version="3.23")
+ def get_by_id(self, virtual_connection: Union[str, VirtualConnectionItem]) -> VirtualConnectionItem:
+ vconn_id = getattr(virtual_connection, "id", virtual_connection)
+ url = f"{self.baseurl}/{vconn_id}"
+ server_response = self.get_request(url)
+ return VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0]
+
+ @api(version="3.23")
+ def download(self, virtual_connection: Union[str, VirtualConnectionItem]) -> str:
+ v_conn = self.get_by_id(virtual_connection)
+ return json.dumps(v_conn.content)
+
+ @api(version="3.23")
+ def update(self, virtual_connection: VirtualConnectionItem) -> VirtualConnectionItem:
+ url = f"{self.baseurl}/{virtual_connection.id}"
+ xml_request = RequestFactory.VirtualConnection.update(virtual_connection)
+ server_response = self.put_request(url, xml_request)
+ return VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0]
+
+ @api(version="3.23")
+ def get_revisions(
+ self, virtual_connection: VirtualConnectionItem, req_options: Optional[RequestOptions] = None
+ ) -> tuple[list[RevisionItem], PaginationItem]:
+ server_response = self.get_request(f"{self.baseurl}/{virtual_connection.id}/revisions", req_options)
+ pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace)
+ revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, virtual_connection)
+ return revisions, pagination_item
+
+ @api(version="3.23")
+ def download_revision(self, virtual_connection: VirtualConnectionItem, revision_number: int) -> str:
+ url = f"{self.baseurl}/{virtual_connection.id}/revisions/{revision_number}"
+ server_response = self.get_request(url)
+ virtual_connection = VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0]
+ return json.dumps(virtual_connection.content)
+
+ @api(version="3.23")
+ def delete(self, virtual_connection: Union[VirtualConnectionItem, str]) -> None:
+ vconn_id = getattr(virtual_connection, "id", virtual_connection)
+ self.delete_request(f"{self.baseurl}/{vconn_id}")
+
+ @api(version="3.23")
+ def publish(
+ self,
+ virtual_connection: VirtualConnectionItem,
+ virtual_connection_content: str,
+ mode: str = "CreateNew",
+ publish_as_draft: bool = False,
+ ) -> VirtualConnectionItem:
+ """
+ Publish a virtual connection to the server.
+
+ For the virtual_connection object, name, project_id, and owner_id are
+ required.
+
+ The virtual_connection_content can be a json string or a file path to a
+ json file.
+
+ The mode can be "CreateNew" or "Overwrite". If mode is
+ "Overwrite" and the virtual connection already exists, it will be
+ overwritten.
+
+ If publish_as_draft is True, the virtual connection will be published
+ as a draft, and the id of the draft will be on the response object.
+ """
+ try:
+ json.loads(virtual_connection_content)
+ except json.JSONDecodeError:
+ file = Path(virtual_connection_content)
+ if not file.exists():
+ raise RuntimeError(f"{virtual_connection_content} is not valid json nor an existing file path")
+ content = file.read_text()
+ else:
+ content = virtual_connection_content
+
+ if mode not in ["CreateNew", "Overwrite"]:
+ raise ValueError(f"Invalid mode: {mode}")
+ overwrite = mode == "Overwrite"
+
+ url = f"{self.baseurl}?overwrite={str(overwrite).lower()}&publishAsDraft={str(publish_as_draft).lower()}"
+ xml_request = RequestFactory.VirtualConnection.publish(virtual_connection, content)
+ server_response = self.post_request(url, xml_request)
+ return VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0]
+
+ @api(version="3.22")
+ def populate_permissions(self, item: VirtualConnectionItem) -> None:
+ self._permissions.populate(item)
+
+ @api(version="3.22")
+ def add_permissions(self, resource, rules):
+ return self._permissions.update(resource, rules)
+
+ @api(version="3.22")
+ def delete_permission(self, item, capability_item):
+ return self._permissions.delete(item, capability_item)
+
+ @api(version="3.23")
+ def add_tags(
+ self, virtual_connection: Union[VirtualConnectionItem, str], tags: Union[Iterable[str], str]
+ ) -> set[str]:
+ return super().add_tags(virtual_connection, tags)
+
+ @api(version="3.23")
+ def delete_tags(
+ self, virtual_connection: Union[VirtualConnectionItem, str], tags: Union[Iterable[str], str]
+ ) -> None:
+ return super().delete_tags(virtual_connection, tags)
+
+ @api(version="3.23")
+ def update_tags(self, virtual_connection: VirtualConnectionItem) -> None:
+ raise NotImplementedError("Update tags is not implemented for Virtual Connections")
diff --git a/tableauserverclient/server/endpoint/webhooks_endpoint.py b/tableauserverclient/server/endpoint/webhooks_endpoint.py
index 6f5135ac1..e5c7b5897 100644
--- a/tableauserverclient/server/endpoint/webhooks_endpoint.py
+++ b/tableauserverclient/server/endpoint/webhooks_endpoint.py
@@ -1,22 +1,43 @@
+import logging
+
from .endpoint import Endpoint, api
-from ...models import WebhookItem, PaginationItem
-from .. import RequestFactory
+from tableauserverclient.server import RequestFactory
+from tableauserverclient.models import WebhookItem, PaginationItem
-import logging
+from tableauserverclient.helpers.logging import logger
-logger = logging.getLogger("tableau.endpoint.webhooks")
+from typing import Optional, TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from ..server import Server
+ from ..request_options import RequestOptions
class Webhooks(Endpoint):
- def __init__(self, parent_srv):
- super(Webhooks, self).__init__(parent_srv)
+ def __init__(self, parent_srv: "Server") -> None:
+ super().__init__(parent_srv)
@property
- def baseurl(self):
- return "{0}/sites/{1}/webhooks".format(self.parent_srv.baseurl, self.parent_srv.site_id)
+ def baseurl(self) -> str:
+ return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/webhooks"
@api(version="3.6")
- def get(self, req_options=None):
+ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[WebhookItem], PaginationItem]:
+ """
+ Returns a list of all webhooks on the site.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#list_webhooks_for_site
+
+ Parameters
+ ----------
+ req_options : Optional[RequestOptions]
+ Filter and sorting options for the request.
+
+ Returns
+ -------
+ tuple[list[WebhookItem], PaginationItem]
+ A tuple of the list of webhooks and pagination item
+ """
logger.info("Querying all Webhooks on site")
url = self.baseurl
server_response = self.get_request(url, req_options)
@@ -25,40 +46,102 @@ 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:
+ """
+ Returns information about a specified Webhook.
+
+ Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#get_webhook
+
+ Parameters
+ ----------
+ webhook_id : str
+ The ID of the webhook to query.
+
+ Returns
+ -------
+ WebhookItem
+ An object containing information about the webhook.
+ """
if not webhook_id:
error = "Webhook ID undefined."
raise ValueError(error)
- logger.info("Querying single webhook (ID: {0})".format(webhook_id))
- url = "{0}/{1}".format(self.baseurl, webhook_id)
+ logger.info(f"Querying single webhook (ID: {webhook_id})")
+ url = f"{self.baseurl}/{webhook_id}"
server_response = self.get_request(url)
return WebhookItem.from_response(server_response.content, self.parent_srv.namespace)[0]
@api(version="3.6")
- def delete(self, webhook_id):
+ def delete(self, webhook_id: str) -> None:
+ """
+ Deletes a specified webhook.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#delete_webhook
+
+ Parameters
+ ----------
+ webhook_id : str
+ The ID of the webhook to delete.
+
+ Returns
+ -------
+ None
+ """
if not webhook_id:
error = "Webhook ID undefined."
raise ValueError(error)
- url = "{0}/{1}".format(self.baseurl, webhook_id)
+ url = f"{self.baseurl}/{webhook_id}"
self.delete_request(url)
- logger.info("Deleted single webhook (ID: {0})".format(webhook_id))
+ logger.info(f"Deleted single webhook (ID: {webhook_id})")
@api(version="3.6")
- def create(self, webhook_item):
+ def create(self, webhook_item: WebhookItem) -> WebhookItem:
+ """
+ Creates a new webhook on the site.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#create_webhook
+
+ Parameters
+ ----------
+ webhook_item : WebhookItem
+ The webhook item to create.
+
+ Returns
+ -------
+ WebhookItem
+ An object containing information about the created webhook
+ """
url = self.baseurl
create_req = RequestFactory.Webhook.create_req(webhook_item)
server_response = self.post_request(url, create_req)
new_webhook = WebhookItem.from_response(server_response.content, self.parent_srv.namespace)[0]
- logger.info("Created new webhook (ID: {0})".format(new_webhook.id))
+ logger.info(f"Created new webhook (ID: {new_webhook.id})")
return new_webhook
@api(version="3.6")
- def test(self, webhook_id):
+ def test(self, webhook_id: str):
+ """
+ Tests the specified webhook. Sends an empty payload to the configured
+ destination URL of the webhook and returns the response from the server.
+ This is useful for testing, to ensure that things are being sent from
+ Tableau and received back as expected.
+
+ Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#test_webhook
+
+ Parameters
+ ----------
+ webhook_id : str
+ The ID of the webhook to test.
+
+ Returns
+ -------
+ XML Response
+
+ """
if not webhook_id:
error = "Webhook ID undefined."
raise ValueError(error)
- url = "{0}/{1}/test".format(self.baseurl, webhook_id)
+ url = f"{self.baseurl}/{webhook_id}/test"
testOutcome = self.get_request(url)
- logger.info("Testing webhook (ID: {0} returned {1})".format(webhook_id, testOutcome))
+ logger.info(f"Testing webhook (ID: {webhook_id} returned {testOutcome})")
return testOutcome
diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py
index a3f14c291..bf4088b9f 100644
--- a/tableauserverclient/server/endpoint/workbooks_endpoint.py
+++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py
@@ -1,43 +1,95 @@
-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 (
+from email.message import Message
+import copy
+import io
+import logging
+import os
+from contextlib import closing
+from pathlib import Path
+
+from tableauserverclient.helpers.headers import fix_filename
+from tableauserverclient.models.permissions_item import PermissionsRule
+from tableauserverclient.server.query import QuerySet
+
+from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api, parameter_added_in
+from tableauserverclient.server.endpoint.exceptions import (
+ InternalServerError,
+ MissingRequiredFieldError,
+ UnsupportedAttributeError,
+)
+from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint
+from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin
+
+from tableauserverclient.filesys_helpers import (
to_filename,
make_download_path,
get_file_type,
get_file_object_size,
)
+from tableauserverclient.helpers import redact_xml
+from tableauserverclient.models import WorkbookItem, ConnectionItem, ViewItem, PaginationItem, JobItem, RevisionItem
+from tableauserverclient.server import RequestFactory
+
+from typing import (
+ Optional,
+ TYPE_CHECKING,
+ Union,
+)
+from collections.abc import Iterable, Sequence
-import os
-import logging
-import copy
-import cgi
-from contextlib import closing
+if TYPE_CHECKING:
+ from tableauserverclient.server import Server
+ from tableauserverclient.server.request_options import RequestOptions, PDFRequestOptions, PPTXRequestOptions
+ from tableauserverclient.models import DatasourceItem
+ from tableauserverclient.server.endpoint.schedules_endpoint import AddResponse
+
+io_types_r = (io.BytesIO, io.BufferedReader)
+io_types_w = (io.BytesIO, io.BufferedWriter)
# The maximum size of a file that can be published in a single request is 64MB
FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB
ALLOWED_FILE_EXTENSIONS = ["twb", "twbx"]
-logger = logging.getLogger("tableau.endpoint.workbooks")
+from tableauserverclient.helpers.logging import logger
+FilePath = Union[str, os.PathLike]
+FileObject = Union[io.BufferedReader, io.BytesIO]
+FileObjectR = Union[io.BufferedReader, io.BytesIO]
+FileObjectW = Union[io.BufferedWriter, io.BytesIO]
+PathOrFileR = Union[FilePath, FileObjectR]
+PathOrFileW = Union[FilePath, FileObjectW]
-class Workbooks(QuerysetEndpoint):
- def __init__(self, parent_srv):
- super(Workbooks, self).__init__(parent_srv)
- self._resource_tagger = _ResourceTagger(parent_srv)
+
+class Workbooks(QuerysetEndpoint[WorkbookItem], TaggingMixin[WorkbookItem]):
+ def __init__(self, parent_srv: "Server") -> None:
+ super().__init__(parent_srv)
self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl)
+ return None
+
@property
- def baseurl(self):
- return "{0}/sites/{1}/workbooks".format(self.parent_srv.baseurl, self.parent_srv.site_id)
+ def baseurl(self) -> str:
+ return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/workbooks"
# Get all workbooks on site
@api(version="2.0")
- def get(self, req_options=None):
+ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[WorkbookItem], PaginationItem]:
+ """
+ Queries the server and returns information about the workbooks the site.
+
+ Parameters
+ ----------
+ req_options : RequestOptions, optional
+ (Optional) You can pass the method a request object that contains
+ additional parameters to filter the request. For example, if you
+ were searching for a specific workbook, you could specify the name
+ of the workbook or the name of the owner.
+
+ Returns
+ -------
+ Tuple containing one page's worth of workbook items and pagination
+ information.
+ """
logger.info("Querying all workbooks on site")
url = self.baseurl
server_response = self.get_request(url, req_options)
@@ -47,29 +99,90 @@ 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:
+ """
+ Returns information about the specified workbook on the site.
+
+ Parameters
+ ----------
+ workbook_id : str
+ The workbook ID.
+
+ Returns
+ -------
+ WorkbookItem
+ The workbook item.
+ """
if not workbook_id:
error = "Workbook ID undefined."
raise ValueError(error)
- logger.info("Querying single workbook (ID: {0})".format(workbook_id))
- url = "{0}/{1}".format(self.baseurl, workbook_id)
+ logger.info(f"Querying single workbook (ID: {workbook_id})")
+ url = f"{self.baseurl}/{workbook_id}"
server_response = self.get_request(url)
return WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0]
@api(version="2.8")
- def refresh(self, workbook_id):
- id_ = getattr(workbook_id, "id", workbook_id)
- url = "{0}/{1}/refresh".format(self.baseurl, id_)
- empty_req = RequestFactory.Empty.empty_req()
- server_response = self.post_request(url, empty_req)
+ def refresh(self, workbook_item: Union[WorkbookItem, str], incremental: bool = False) -> JobItem:
+ """
+ Refreshes the extract of an existing workbook.
+
+ Parameters
+ ----------
+ workbook_item : WorkbookItem | str
+ The workbook item or workbook ID.
+ incremental: bool
+ Whether to do a full refresh or incremental refresh of the extract data
+
+ Returns
+ -------
+ JobItem
+ The job item.
+ """
+ id_ = getattr(workbook_item, "id", workbook_item)
+ url = f"{self.baseurl}/{id_}/refresh"
+ refresh_req = RequestFactory.Task.refresh_req(incremental, self.parent_srv)
+ server_response = self.post_request(url, refresh_req)
new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0]
return new_job
# 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:
+ """
+ Create one or more extracts on 1 workbook, optionally encrypted.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#create_extracts_for_workbook
+
+ Parameters
+ ----------
+ workbook_item : WorkbookItem
+ The workbook item to create extracts for.
+
+ encrypt : bool, default False
+ Set to True to encrypt the extracts.
+
+ includeAll : bool, default True
+ If True, all data sources in the workbook will have an extract
+ created for them. If False, then a data source must be supplied in
+ the request.
+
+ datasources : list[DatasourceItem] | None
+ List of DatasourceItem objects for the data sources to create
+ extracts for. Only required if includeAll is False.
+
+ Returns
+ -------
+ JobItem
+ The job item for the extract creation.
+ """
id_ = getattr(workbook_item, "id", workbook_item)
- url = "{0}/{1}/createExtract?encrypt={2}".format(self.baseurl, id_, encrypt)
+ url = f"{self.baseurl}/{id_}/createExtract?encrypt={encrypt}"
datasource_req = RequestFactory.Workbook.embedded_extract_req(includeAll, datasources)
server_response = self.post_request(url, datasource_req)
@@ -77,109 +190,231 @@ 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, datasources=None) -> JobItem:
+ """
+ Delete all extracts of embedded datasources on 1 workbook.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#delete_extracts_from_workbook
+
+ Parameters
+ ----------
+ workbook_item : WorkbookItem
+ The workbook item to delete extracts from.
+
+ includeAll : bool, default True
+ If True, all data sources in the workbook will have their extracts
+ deleted. If False, then a data source must be supplied in the
+ request.
+
+ datasources : list[DatasourceItem] | None
+ List of DatasourceItem objects for the data sources to delete
+ extracts from. Only required if includeAll is False.
+
+ Returns
+ -------
+ JobItem
+ """
id_ = getattr(workbook_item, "id", workbook_item)
- url = "{0}/{1}/deleteExtract".format(self.baseurl, id_)
- empty_req = RequestFactory.Empty.empty_req()
- server_response = self.post_request(url, empty_req)
+ url = f"{self.baseurl}/{id_}/deleteExtract"
+ datasource_req = RequestFactory.Workbook.embedded_extract_req(includeAll, datasources)
+ server_response = self.post_request(url, datasource_req)
+ new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0]
+ return new_job
# Delete 1 workbook by id
@api(version="2.0")
- def delete(self, workbook_id):
+ def delete(self, workbook_id: str) -> None:
+ """
+ Deletes a workbook with the specified ID.
+
+ Parameters
+ ----------
+ workbook_id : str
+ The workbook ID.
+
+ Returns
+ -------
+ None
+ """
if not workbook_id:
error = "Workbook ID undefined."
raise ValueError(error)
- url = "{0}/{1}".format(self.baseurl, workbook_id)
+ url = f"{self.baseurl}/{workbook_id}"
self.delete_request(url)
- logger.info("Deleted single workbook (ID: {0})".format(workbook_id))
+ logger.info(f"Deleted single workbook (ID: {workbook_id})")
# Update workbook
@api(version="2.0")
- def update(self, workbook_item):
+ @parameter_added_in(include_view_acceleration_status="3.22")
+ def update(
+ self,
+ workbook_item: WorkbookItem,
+ include_view_acceleration_status: bool = False,
+ ) -> WorkbookItem:
+ """
+ Modifies an existing workbook. Use this method to change the owner or
+ the project that the workbook belongs to, or to change whether the
+ workbook shows views in tabs. The workbook item must include the
+ workbook ID and overrides the existing settings.
+
+ See https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#update_workbook
+ for a list of fields that can be updated.
+
+ Parameters
+ ----------
+ workbook_item : WorkbookItem
+ The workbook item to update. ID is required. Other fields are
+ optional. Any fields that are not specified will not be changed.
+
+ include_view_acceleration_status : bool, default False
+ Set to True to include the view acceleration status in the response.
+
+ Returns
+ -------
+ WorkbookItem
+ The updated workbook item.
+ """
if not workbook_item.id:
error = "Workbook item missing ID. Workbook must be retrieved from server first."
raise MissingRequiredFieldError(error)
- self._resource_tagger.update_tags(self.baseurl, workbook_item)
+ self.update_tags(workbook_item)
# Update the workbook itself
- url = "{0}/{1}".format(self.baseurl, workbook_item.id)
- update_req = RequestFactory.Workbook.update_req(workbook_item)
+ url = f"{self.baseurl}/{workbook_item.id}"
+ if include_view_acceleration_status:
+ url += "?includeViewAccelerationStatus=True"
+
+ update_req = RequestFactory.Workbook.update_req(workbook_item, self.parent_srv)
server_response = self.put_request(url, update_req)
- logger.info("Updated workbook item (ID: {0})".format(workbook_item.id))
+ logger.info(f"Updated workbook item (ID: {workbook_item.id})")
updated_workbook = copy.copy(workbook_item)
return updated_workbook._parse_common_tags(server_response.content, self.parent_srv.namespace)
- @api(version="2.3")
- def update_conn(self, *args, **kwargs):
- import warnings
-
- warnings.warn("update_conn is deprecated, please use update_connection instead")
- return self.update_connection(*args, **kwargs)
-
# Update workbook_connection
@api(version="2.3")
- def update_connection(self, workbook_item, connection_item):
- url = "{0}/{1}/connections/{2}".format(self.baseurl, workbook_item.id, connection_item.id)
+ def update_connection(self, workbook_item: WorkbookItem, connection_item: ConnectionItem) -> ConnectionItem:
+ """
+ Updates a workbook connection information (server addres, server port,
+ user name, and password).
+
+ The workbook connections must be populated before the strings can be
+ updated.
+
+ Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#update_workbook_connection
+
+ Parameters
+ ----------
+ workbook_item : WorkbookItem
+ The workbook item to update.
+
+ connection_item : ConnectionItem
+ The connection item to update.
+
+ Returns
+ -------
+ ConnectionItem
+ The updated connection item.
+ """
+ url = f"{self.baseurl}/{workbook_item.id}/connections/{connection_item.id}"
update_req = RequestFactory.Connection.update_req(connection_item)
server_response = self.put_request(url, update_req)
connection = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0]
- logger.info(
- "Updated workbook item (ID: {0} & connection item {1})".format(workbook_item.id, connection_item.id)
- )
+ logger.info(f"Updated workbook item (ID: {workbook_item.id} & connection item {connection_item.id})")
return connection
# Download workbook contents with option of passing in filepath
@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):
- if not workbook_id:
- error = "Workbook ID undefined."
- raise ValueError(error)
- url = "{0}/{1}/content".format(self.baseurl, workbook_id)
-
- if no_extract is False or no_extract is True:
- import warnings
-
- warnings.warn(
- "no_extract is deprecated, use include_extract instead.",
- DeprecationWarning,
- )
- include_extract = not no_extract
-
- if not include_extract:
- url += "?includeExtract=False"
-
- with closing(self.get_request(url, parameters={"stream": True})) as server_response:
- _, params = cgi.parse_header(server_response.headers["Content-Disposition"])
- filename = to_filename(os.path.basename(params["filename"]))
-
- download_path = make_download_path(filepath, filename)
-
- with open(download_path, "wb") as f:
- for chunk in server_response.iter_content(1024): # 1KB
- f.write(chunk)
- logger.info("Downloaded workbook to {0} (ID: {1})".format(download_path, workbook_id))
- return os.path.abspath(download_path)
+ def download(
+ self,
+ workbook_id: str,
+ filepath: Optional[PathOrFileW] = None,
+ include_extract: bool = True,
+ ) -> PathOrFileW:
+ """
+ Downloads a workbook to the specified directory (optional).
+
+ Parameters
+ ----------
+ workbook_id : str
+ The workbook ID.
+
+ filepath : Path or File object, optional
+ Downloads the file to the location you specify. If no location is
+ specified, the file is downloaded to the current working directory.
+ The default is Filepath=None.
+
+ include_extract : bool, default True
+ Set to False to exclude the extract from the download. The default
+ is True.
+
+ Returns
+ -------
+ Path or File object
+ The path to the downloaded workbook or the file object.
+
+ Raises
+ ------
+ ValueError
+ If the workbook ID is not defined.
+ """
+
+ return self.download_revision(
+ workbook_id,
+ None,
+ filepath,
+ include_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:
+ """
+ Populates (or gets) a list of views for a workbook.
+
+ You must first call this method to populate views before you can iterate
+ through the views.
+
+ This method retrieves the view information for the specified workbook.
+ The REST API is designed to return only the information you ask for
+ explicitly. When you query for all the workbooks, the view information
+ is not included. Use this method to retrieve the views. The method adds
+ the list of views to the workbook item (workbook_item.views). This is a
+ list of ViewItem.
+
+ Parameters
+ ----------
+ workbook_item : WorkbookItem
+ The workbook item to populate views for.
+
+ usage : bool, default False
+ Set to True to include usage statistics for each view.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ MissingRequiredFieldError
+ If the workbook item is missing an ID.
+ """
if not workbook_item.id:
error = "Workbook item missing ID. Workbook must be retrieved from server first."
raise MissingRequiredFieldError(error)
- def view_fetcher():
+ def view_fetcher() -> list[ViewItem]:
return self._get_views_for_workbook(workbook_item, usage)
workbook_item._set_views(view_fetcher)
- logger.info("Populated views for workbook (ID: {0})".format(workbook_item.id))
+ logger.info(f"Populated views for workbook (ID: {workbook_item.id})")
- def _get_views_for_workbook(self, workbook_item, usage):
- url = "{0}/{1}/views".format(self.baseurl, workbook_item.id)
+ def _get_views_for_workbook(self, workbook_item: WorkbookItem, usage: bool) -> list[ViewItem]:
+ url = f"{self.baseurl}/{workbook_item.id}/views"
if usage:
url += "?includeUsageStatistics=true"
server_response = self.get_request(url)
@@ -192,7 +427,37 @@ 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:
+ """
+ Populates a list of data source connections for the specified workbook.
+
+ You must populate connections before you can iterate through the
+ connections.
+
+ This method retrieves the data source connection information for the
+ specified workbook. The REST API is designed to return only the
+ information you ask for explicitly. When you query all the workbooks,
+ the data source connection information is not included. Use this method
+ to retrieve the connection information for any data sources used by the
+ workbook. The method adds the list of data connections to the workbook
+ item (workbook_item.connections). This is a list of ConnectionItem.
+
+ REST API docs: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_workbook_connections
+
+ Parameters
+ ----------
+ workbook_item : WorkbookItem
+ The workbook item to populate connections for.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ MissingRequiredFieldError
+ If the workbook item is missing an ID.
+ """
if not workbook_item.id:
error = "Workbook item missing ID. Workbook must be retrieved from server first."
raise MissingRequiredFieldError(error)
@@ -201,93 +466,317 @@ def connection_fetcher():
return self._get_workbook_connections(workbook_item)
workbook_item._set_connections(connection_fetcher)
- logger.info("Populated connections for workbook (ID: {0})".format(workbook_item.id))
+ logger.info(f"Populated connections for workbook (ID: {workbook_item.id})")
- def _get_workbook_connections(self, workbook_item, req_options=None):
- url = "{0}/{1}/connections".format(self.baseurl, workbook_item.id)
+ def _get_workbook_connections(
+ self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None
+ ) -> list[ConnectionItem]:
+ url = f"{self.baseurl}/{workbook_item.id}/connections"
server_response = self.get_request(url, req_options)
connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)
return connections
- # Get the pdf of the entire workbook if its tabs are enabled, pdf of the default view if its tabs are disabled
@api(version="3.4")
- def populate_pdf(self, workbook_item, req_options=None):
+ def populate_pdf(self, workbook_item: WorkbookItem, req_options: Optional["PDFRequestOptions"] = None) -> None:
+ """
+ Populates the PDF for the specified workbook item. Get the pdf of the
+ entire workbook if its tabs are enabled, pdf of the default view if its
+ tabs are disabled.
+
+ This method populates a PDF with image(s) of the workbook view(s) you
+ specify.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#download_workbook_pdf
+
+ Parameters
+ ----------
+ workbook_item : WorkbookItem
+ The workbook item to populate the PDF for.
+
+ req_options : PDFRequestOptions, optional
+ (Optional) You can pass in request options to specify the page type
+ and orientation of the PDF content, as well as the maximum age of
+ the PDF rendered on the server. See PDFRequestOptions class for more
+ details.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ MissingRequiredFieldError
+ If the workbook item is missing an ID.
+ """
if not workbook_item.id:
error = "Workbook item missing ID."
raise MissingRequiredFieldError(error)
- def pdf_fetcher():
+ def pdf_fetcher() -> bytes:
return self._get_wb_pdf(workbook_item, req_options)
+ if not self.parent_srv.check_at_least_version("3.23") and req_options is not None:
+ if req_options.view_filters or req_options.view_parameters:
+ raise UnsupportedAttributeError("view_filters and view_parameters are only supported in 3.23+")
+
+ if req_options.viz_height or req_options.viz_width:
+ raise UnsupportedAttributeError("viz_height and viz_width are only supported in 3.23+")
+
workbook_item._set_pdf(pdf_fetcher)
- logger.info("Populated pdf for workbook (ID: {0})".format(workbook_item.id))
+ logger.info(f"Populated pdf for workbook (ID: {workbook_item.id})")
- def _get_wb_pdf(self, workbook_item, req_options):
- url = "{0}/{1}/pdf".format(self.baseurl, workbook_item.id)
+ def _get_wb_pdf(self, workbook_item: WorkbookItem, req_options: Optional["PDFRequestOptions"]) -> bytes:
+ url = f"{self.baseurl}/{workbook_item.id}/pdf"
server_response = self.get_request(url, req_options)
pdf = server_response.content
return pdf
+ @api(version="3.8")
+ def populate_powerpoint(
+ self, workbook_item: WorkbookItem, req_options: Optional["PPTXRequestOptions"] = None
+ ) -> None:
+ """
+ Populates the PowerPoint for the specified workbook item.
+
+ This method populates a PowerPoint with image(s) of the workbook view(s) you
+ specify.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#download_workbook_powerpoint
+
+ Parameters
+ ----------
+ workbook_item : WorkbookItem
+ The workbook item to populate the PDF for.
+
+ req_options : RequestOptions, optional
+ (Optional) You can pass in request options to specify the maximum
+ number of minutes a workbook .pptx will be cached before being
+ refreshed. To prevent multiple .pptx requests from overloading the
+ server, the shortest interval you can set is one minute. There is no
+ maximum value, but the server job enacting the caching action may
+ expire before a long cache period is reached.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ MissingRequiredFieldError
+ If the workbook item is missing an ID.
+ """
+ if not workbook_item.id:
+ error = "Workbook item missing ID."
+ raise MissingRequiredFieldError(error)
+
+ def pptx_fetcher() -> bytes:
+ return self._get_wb_pptx(workbook_item, req_options)
+
+ workbook_item._set_powerpoint(pptx_fetcher)
+ logger.info(f"Populated powerpoint for workbook (ID: {workbook_item.id})")
+
+ def _get_wb_pptx(self, workbook_item: WorkbookItem, req_options: Optional["PPTXRequestOptions"]) -> bytes:
+ url = f"{self.baseurl}/{workbook_item.id}/powerpoint"
+ server_response = self.get_request(url, req_options)
+ pptx = server_response.content
+ 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:
+ """
+ This method gets the preview image (thumbnail) for the specified workbook item.
+
+ This method uses the workbook's ID to get the preview image. The method
+ adds the preview image to the workbook item (workbook_item.preview_image).
+
+ Parameters
+ ----------
+ workbook_item : WorkbookItem
+ The workbook item to populate the preview image for.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ MissingRequiredFieldError
+ If the workbook item is missing an ID.
+ """
if not workbook_item.id:
error = "Workbook item missing ID. Workbook must be retrieved from server first."
raise MissingRequiredFieldError(error)
- 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))
+ logger.info(f"Populated preview image for workbook (ID: {workbook_item.id})")
- def _get_wb_preview_image(self, workbook_item):
- url = "{0}/{1}/previewImage".format(self.baseurl, workbook_item.id)
+ def _get_wb_preview_image(self, workbook_item: WorkbookItem) -> bytes:
+ url = f"{self.baseurl}/{workbook_item.id}/previewImage"
server_response = self.get_request(url)
preview_image = server_response.content
return preview_image
@api(version="2.0")
- def populate_permissions(self, item):
+ def populate_permissions(self, item: WorkbookItem) -> None:
+ """
+ Populates the permissions for the specified workbook item.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_workbook_permissions
+
+ Parameters
+ ----------
+ item : WorkbookItem
+ The workbook item to populate permissions for.
+
+ Returns
+ -------
+ None
+ """
self._permissions.populate(item)
@api(version="2.0")
- def update_permissions(self, resource, rules):
+ def update_permissions(self, resource: WorkbookItem, rules: list[PermissionsRule]) -> list[PermissionsRule]:
+ """
+ Updates the permissions for the specified workbook item. The method
+ replaces the existing permissions with the new permissions. Any missing
+ permissions are removed.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_permissions_for_content
+
+ Parameters
+ ----------
+ resource : WorkbookItem
+ The workbook item to update permissions for.
+
+ rules : list[PermissionsRule]
+ A list of permissions rules to apply to the workbook item.
+
+ Returns
+ -------
+ list[PermissionsRule]
+ The updated permissions rules.
+ """
return self._permissions.update(resource, rules)
@api(version="2.0")
- def delete_permission(self, item, capability_item):
+ def delete_permission(self, item: WorkbookItem, capability_item: PermissionsRule) -> None:
+ """
+ Deletes a single permission rule from the specified workbook item.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_workbook_permission
+
+ Parameters
+ ----------
+ item : WorkbookItem
+ The workbook item to delete the permission from.
+
+ capability_item : PermissionsRule
+ The permission rule to delete.
+
+ Returns
+ -------
+ None
+ """
return self._permissions.delete(item, capability_item)
- # 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: PathOrFileR,
+ mode: str,
+ connections: Optional[Sequence[ConnectionItem]] = None,
+ as_job: bool = False,
+ skip_connection_check: bool = False,
+ parameters=None,
):
-
- if connection_credentials is not None:
- import warnings
-
- warnings.warn(
- "connection_credentials is being deprecated. Use connections instead",
- DeprecationWarning,
- )
-
- try:
- # Expect file to be a filepath
+ """
+ Publish a workbook to the specified site.
+
+ Note: The REST API cannot automatically include extracts or other
+ resources that the workbook uses. Therefore, a .twb file that uses data
+ from an Excel or csv file on a local computer cannot be published,
+ unless you package the data and workbook in a .twbx file, or publish the
+ data source separately.
+
+ For workbooks that are larger than 64 MB, the publish method
+ automatically takes care of chunking the file in parts for uploading.
+ Using this method is considerably more convenient than calling the
+ publish REST APIs directly.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#publish_workbook
+
+ Parameters
+ ----------
+ workbook_item : WorkbookItem
+ The workbook_item specifies the workbook you are publishing. When
+ you are adding a workbook, you need to first create a new instance
+ of a workbook_item that includes a project_id of an existing
+ project. The name of the workbook will be the name of the file,
+ unless you also specify a name for the new workbook when you create
+ the instance.
+
+ file : Path or File object
+ The file path or file object of the workbook to publish. When
+ providing a file object, you must also specifiy the name of the
+ workbook in your instance of the workbook_itemworkbook_item , as
+ the name cannot be derived from the file name.
+
+ mode : str
+ Specifies whether you are publishing a new workbook (CreateNew) or
+ overwriting an existing workbook (Overwrite). You cannot appending
+ workbooks. You can also use the publish mode attributes, for
+ example: TSC.Server.PublishMode.Overwrite.
+
+ connections : list[ConnectionItem] | None
+ List of ConnectionItems objects for the connections created within
+ the workbook.
+
+ as_job : bool, default False
+ Set to True to run the upload as a job (asynchronous upload). If set
+ to True a job will start to perform the publishing process and a Job
+ object is returned. Defaults to False.
+
+ skip_connection_check : bool, default False
+ Set to True to skip connection check at time of upload. Publishing
+ will succeed but unchecked connection issues may result in a
+ non-functioning workbook. Defaults to False.
+
+ Raises
+ ------
+ OSError
+ If the file path does not lead to an existing file.
+
+ ServerResponseError
+ If the server response is not successful.
+
+ TypeError
+ If the file is not a file path or file object.
+
+ ValueError
+ If the file extension is not supported
+
+ ValueError
+ If the mode is invalid.
+
+ ValueError
+ Workbooks cannot be appended.
+
+ Returns
+ -------
+ WorkbookItem | JobItem
+ The workbook item or job item that was published.
+ """
+ if isinstance(file, (str, os.PathLike)):
if not os.path.isfile(file):
error = "File path does not lead to an existing file."
- raise IOError(error)
+ raise OSError(error)
filename = os.path.basename(file)
file_extension = os.path.splitext(filename)[1][1:]
@@ -300,82 +789,79 @@ def publish(
error = "Only {} files can be published as workbooks.".format(", ".join(ALLOWED_FILE_EXTENSIONS))
raise ValueError(error)
- except TypeError:
- # Expect file to be a file object
- file_size = get_file_object_size(file)
+ elif isinstance(file, io_types_r):
+ if not workbook_item.name:
+ error = "Workbook item must have a name when passing a file object"
+ raise ValueError(error)
file_type = get_file_type(file)
-
if file_type == "zip":
file_extension = "twbx"
elif file_type == "xml":
file_extension = "twb"
else:
- error = "Unsupported file type {}!".format(file_type)
- raise ValueError(error)
-
- if not workbook_item.name:
- error = "Workbook item must have a name when passing a file object"
+ error = f"Unsupported file type {file_type}!"
raise ValueError(error)
# Generate filename for file object.
# This is needed when publishing the workbook in a single request
- filename = "{}.{}".format(workbook_item.name, file_extension)
+ filename = f"{workbook_item.name}.{file_extension}"
+ file_size = get_file_object_size(file)
+
+ else:
+ 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)
# Construct the url with the defined mode
- url = "{0}?workbookType={1}".format(self.baseurl, file_extension)
+ url = f"{self.baseurl}?workbookType={file_extension}"
if mode == self.parent_srv.PublishMode.Overwrite:
- url += "&{0}=true".format(mode.lower())
+ url += f"&{mode.lower()}=true"
elif mode == self.parent_srv.PublishMode.Append:
error = "Workbooks cannot be appended."
raise ValueError(error)
if as_job:
- url += "&{0}=true".format("asJob")
+ url += "&{}=true".format("asJob")
if skip_connection_check:
- url += "&{0}=true".format("skipConnectionCheck")
+ url += "&{}=true".format("skipConnectionCheck")
# Determine if chunking is required (64MB is the limit for single upload method)
if file_size >= FILESIZE_LIMIT:
- logger.info("Publishing {0} to server with chunking method (workbook over 64MB)".format(workbook_item.name))
+ logger.info(f"Publishing {workbook_item.name} to server with chunking method (workbook over 64MB)")
upload_session_id = self.parent_srv.fileuploads.upload(file)
- url = "{0}&uploadSessionId={1}".format(url, upload_session_id)
- conn_creds = connection_credentials
+ url = f"{url}&uploadSessionId={upload_session_id}"
xml_request, content_type = RequestFactory.Workbook.publish_req_chunked(
workbook_item,
- connection_credentials=conn_creds,
connections=connections,
- hidden_views=hidden_views,
)
else:
- logger.info("Publishing {0} to server".format(filename))
+ logger.info(f"Publishing {filename} to server")
- try:
+ if isinstance(file, (str, Path)):
with open(file, "rb") as f:
file_contents = f.read()
- except TypeError:
+ elif isinstance(file, io_types_r):
file_contents = file.read()
- conn_creds = connection_credentials
+ else:
+ raise TypeError("file should be a filepath or file object.")
+
xml_request, content_type = RequestFactory.Workbook.publish_req(
workbook_item,
filename,
file_contents,
- connection_credentials=conn_creds,
connections=connections,
- hidden_views=hidden_views,
)
- logger.debug("Request xml: {0} ".format(xml_request[:1000]))
+ logger.debug(f"Request xml: {redact_xml(xml_request[:1000])} ")
# Send the publishing request to server
try:
- server_response = self.post_request(url, xml_request, content_type)
+ server_response = self.post_request(url, xml_request, content_type, parameters)
except InternalServerError as err:
if err.code == 504 and not as_job:
err.content = "Timeout error while publishing. Please use asynchronous publishing to avoid timeouts."
@@ -383,9 +869,315 @@ def publish(
if as_job:
new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0]
- logger.info("Published {0} (JOB_ID: {1}".format(workbook_item.name, new_job.id))
+ logger.info(f"Published {workbook_item.name} (JOB_ID: {new_job.id}")
return new_job
else:
new_workbook = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0]
- logger.info("Published {0} (ID: {1})".format(workbook_item.name, new_workbook.id))
+ logger.info(f"Published {workbook_item.name} (ID: {new_workbook.id})")
return new_workbook
+
+ # Populate workbook item's revisions
+ @api(version="2.3")
+ def populate_revisions(self, workbook_item: WorkbookItem) -> None:
+ """
+ Populates (or gets) a list of revisions for a workbook.
+
+ You must first call this method to populate revisions before you can
+ iterate through the revisions.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#get_workbook_revisions
+
+ Parameters
+ ----------
+ workbook_item : WorkbookItem
+ The workbook item to populate revisions for.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ MissingRequiredFieldError
+ If the workbook item is missing an ID.
+ """
+ if not workbook_item.id:
+ error = "Workbook item missing ID. Workbook must be retrieved from server first."
+ raise MissingRequiredFieldError(error)
+
+ def revisions_fetcher():
+ return self._get_workbook_revisions(workbook_item)
+
+ workbook_item._set_revisions(revisions_fetcher)
+ logger.info(f"Populated revisions for workbook (ID: {workbook_item.id})")
+
+ def _get_workbook_revisions(
+ self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None
+ ) -> list[RevisionItem]:
+ url = f"{self.baseurl}/{workbook_item.id}/revisions"
+ server_response = self.get_request(url, req_options)
+ revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, workbook_item)
+ return revisions
+
+ # Download 1 workbook revision by revision number
+ @api(version="2.3")
+ def download_revision(
+ self,
+ workbook_id: str,
+ revision_number: Optional[str],
+ filepath: Optional[PathOrFileW] = None,
+ include_extract: bool = True,
+ ) -> PathOrFileW:
+ """
+ Downloads a workbook revision to the specified directory (optional).
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#download_workbook_revision
+
+ Parameters
+ ----------
+ workbook_id : str
+ The workbook ID.
+
+ revision_number : str | None
+ The revision number of the workbook. If None, the latest revision is
+ downloaded.
+
+ filepath : Path or File object, optional
+ Downloads the file to the location you specify. If no location is
+ specified, the file is downloaded to the current working directory.
+ The default is Filepath=None.
+
+ include_extract : bool, default True
+ Set to False to exclude the extract from the download. The default
+ is True.
+
+ Returns
+ -------
+ Path or File object
+ The path to the downloaded workbook or the file object.
+
+ Raises
+ ------
+ ValueError
+ If the workbook ID is not defined.
+ """
+
+ if not workbook_id:
+ error = "Workbook ID undefined."
+ raise ValueError(error)
+ if revision_number is None:
+ url = f"{self.baseurl}/{workbook_id}/content"
+ else:
+ url = f"{self.baseurl}/{workbook_id}/revisions/{revision_number}/content"
+
+ if not include_extract:
+ url += "?includeExtract=False"
+
+ with closing(self.get_request(url, parameters={"stream": True})) as server_response:
+ m = Message()
+ m["Content-Disposition"] = server_response.headers["Content-Disposition"]
+ params = m.get_filename(failobj="")
+ if isinstance(filepath, io_types_w):
+ for chunk in server_response.iter_content(1024): # 1KB
+ filepath.write(chunk)
+ return_path = filepath
+ else:
+ params = fix_filename(params)
+ filename = to_filename(os.path.basename(params))
+ download_path = make_download_path(filepath, filename)
+ with open(download_path, "wb") as f:
+ for chunk in server_response.iter_content(1024): # 1KB
+ f.write(chunk)
+ return_path = os.path.abspath(download_path)
+
+ logger.info(f"Downloaded workbook revision {revision_number} to {return_path} (ID: {workbook_id})")
+ return return_path
+
+ @api(version="2.3")
+ def delete_revision(self, workbook_id: str, revision_number: str) -> None:
+ """
+ Deletes a specific revision from a workbook on Tableau Server.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_revisions.htm#remove_workbook_revision
+
+ Parameters
+ ----------
+ workbook_id : str
+ The workbook ID.
+
+ revision_number : str
+ The revision number of the workbook to delete.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ ValueError
+ If the workbook ID or revision number is not defined.
+ """
+ if workbook_id is None or revision_number is None:
+ raise ValueError
+ url = "/".join([self.baseurl, workbook_id, "revisions", revision_number])
+
+ self.delete_request(url)
+ logger.info(f"Deleted single workbook revision (ID: {workbook_id}) (Revision: {revision_number})")
+
+ # a convenience method
+ @api(version="2.8")
+ def schedule_extract_refresh(
+ self, schedule_id: str, item: WorkbookItem
+ ) -> list["AddResponse"]: # actually should return a task
+ """
+ Adds a workbook to a schedule for extract refresh.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#add_workbook_to_schedule
+
+ Parameters
+ ----------
+ schedule_id : str
+ The schedule ID.
+
+ item : WorkbookItem
+ The workbook item to add to the schedule.
+
+ Returns
+ -------
+ list[AddResponse]
+ The response from the server.
+ """
+ return self.parent_srv.schedules.add_to_schedule(schedule_id, workbook=item)
+
+ @api(version="1.0")
+ def add_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> set[str]:
+ """
+ Adds tags to a workbook. One or more tags may be added at a time. If a
+ tag already exists on the workbook, it will not be duplicated.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#add_tags_to_workbook
+
+ Parameters
+ ----------
+ item : WorkbookItem | str
+ The workbook item or workbook ID to add tags to.
+
+ tags : Iterable[str] | str
+ The tag or tags to add to the workbook. Tags can be a single tag or
+ a list of tags.
+
+ Returns
+ -------
+ set[str]
+ The set of tags added to the workbook.
+ """
+ return super().add_tags(item, tags)
+
+ @api(version="1.0")
+ def delete_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> None:
+ """
+ Deletes tags from a workbook. One or more tags may be deleted at a time.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#delete_tag_from_workbook
+
+ Parameters
+ ----------
+ item : WorkbookItem | str
+ The workbook item or workbook ID to delete tags from.
+
+ tags : Iterable[str] | str
+ The tag or tags to delete from the workbook. Tags can be a single
+ tag or a list of tags.
+
+ Returns
+ -------
+ None
+ """
+ return super().delete_tags(item, tags)
+
+ @api(version="1.0")
+ def update_tags(self, item: WorkbookItem) -> None:
+ """
+ Updates the tags on a workbook. This method is used to update the tags
+ on the server to match the tags on the workbook item. This method is a
+ convenience method that calls add_tags and delete_tags to update the
+ tags on the server.
+
+ Parameters
+ ----------
+ item : WorkbookItem
+ The workbook item to update the tags for. The tags on the workbook
+ item will be used to update the tags on the server.
+
+ Returns
+ -------
+ None
+ """
+ return super().update_tags(item)
+
+ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[WorkbookItem]:
+ """
+ Queries the Tableau Server for items using the specified filters. Page
+ size can be specified to limit the number of items returned in a single
+ request. If not specified, the default page size is 100. Page size can
+ be an integer between 1 and 1000.
+
+ No positional arguments are allowed. All filters must be specified as
+ keyword arguments. If you use the equality operator, you can specify it
+ through =. If you want to use a different operator,
+ you can specify it through __=. Field
+ names can either be in snake_case or camelCase.
+
+ This endpoint supports the following fields and operators:
+
+
+ created_at=...
+ created_at__gt=...
+ created_at__gte=...
+ created_at__lt=...
+ created_at__lte=...
+ content_url=...
+ content_url__in=...
+ display_tabs=...
+ favorites_total=...
+ favorites_total__gt=...
+ favorites_total__gte=...
+ favorites_total__lt=...
+ favorites_total__lte=...
+ has_alerts=...
+ has_extracts=...
+ name=...
+ name__in=...
+ owner_domain=...
+ owner_domain__in=...
+ owner_email=...
+ owner_email__in=...
+ owner_name=...
+ owner_name__in=...
+ project_name=...
+ project_name__in=...
+ sheet_count=...
+ sheet_count__gt=...
+ sheet_count__gte=...
+ sheet_count__lt=...
+ sheet_count__lte=...
+ size=...
+ size__gt=...
+ size__gte=...
+ size__lt=...
+ size__lte=...
+ subscriptions_total=...
+ subscriptions_total__gt=...
+ subscriptions_total__gte=...
+ subscriptions_total__lt=...
+ subscriptions_total__lte=...
+ tags=...
+ tags__in=...
+ updated_at=...
+ updated_at__gt=...
+ updated_at__gte=...
+ updated_at__lt=...
+ updated_at__lte=...
+ """
+
+ return super().filter(*invalid, page_size=page_size, **kwargs)
diff --git a/tableauserverclient/server/exceptions.py b/tableauserverclient/server/exceptions.py
index 09d3d0541..6c9bbcefc 100644
--- a/tableauserverclient/server/exceptions.py
+++ b/tableauserverclient/server/exceptions.py
@@ -1,2 +1,9 @@
-class NotSignedInError(Exception):
+# These errors can be thrown without even talking to Tableau Server
+
+
+class ServerInfoEndpointNotFoundError(Exception):
+ pass
+
+
+class EndpointUnavailableError(Exception):
pass
diff --git a/tableauserverclient/server/filter.py b/tableauserverclient/server/filter.py
index 8802321fd..fd90e281f 100644
--- a/tableauserverclient/server/filter.py
+++ b/tableauserverclient/server/filter.py
@@ -1,7 +1,7 @@
from .request_options import RequestOptions
-class Filter(object):
+class Filter:
def __init__(self, field, operator, value):
self.field = field
self.operator = operator
@@ -11,8 +11,12 @@ def __init__(self, field, operator, value):
def __str__(self):
value_string = str(self._value)
if isinstance(self._value, list):
- value_string = value_string.replace(" ", "").replace("'", "")
- return "{0}:{1}:{2}".format(self.field, self.operator, value_string)
+ # this should turn the string representation of the list
+ # from ['', '', ...]
+ # to [,]
+ # so effectively, remove any spaces between "," and "'" and then remove all "'"
+ value_string = value_string.replace(", '", ",'").replace("'", "")
+ return f"{self.field}:{self.operator}:{value_string}"
@property
def value(self):
diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py
index 2de84b4d1..3c7e60f74 100644
--- a/tableauserverclient/server/pager.py
+++ b/tableauserverclient/server/pager.py
@@ -1,24 +1,69 @@
+import copy
from functools import partial
+from typing import Optional, Protocol, TypeVar, Union, runtime_checkable
+from collections.abc import Iterable, Iterator
-from . import RequestOptions
+from tableauserverclient.models.pagination_item import PaginationItem
+from tableauserverclient.server.request_options import RequestOptions
-class Pager(object):
+T = TypeVar("T")
+
+
+@runtime_checkable
+class Endpoint(Protocol[T]):
+ def get(self, req_options: Optional[RequestOptions]) -> tuple[list[T], PaginationItem]: ...
+
+
+@runtime_checkable
+class CallableEndpoint(Protocol[T]):
+ def __call__(self, __req_options: Optional[RequestOptions], **kwargs) -> tuple[list[T], PaginationItem]: ...
+
+
+class Pager(Iterable[T]):
"""
Generator that takes an endpoint (top level endpoints with `.get)` and lazily loads items from Server.
Supports all `RequestOptions` including starting on any page. Also used by models to load sub-models
(users in a group, views in a workbook, etc) by passing a different endpoint.
- Will loop over anything that returns (List[ModelItem], PaginationItem).
- """
+ Will loop over anything that returns (list[ModelItem], PaginationItem).
+
+ Will make a copy of the `RequestOptions` object passed in so it can be reused.
+
+ Makes a call to the Server for each page of items, then yields each item in the list.
+
+ Parameters
+ ----------
+ endpoint: CallableEndpoint[T] or Endpoint[T]
+ The endpoint to call to get the items. Can be a callable or an Endpoint object.
+ Expects a tuple of (list[T], PaginationItem) to be returned.
- def __init__(self, endpoint, request_opts=None, **kwargs):
+ request_opts: RequestOptions, optional
+ The request options to pass to the endpoint. If not provided, will use default RequestOptions.
+ Filters, sorts, page size, starting page number, etc can be set here.
- if hasattr(endpoint, "get"):
+ Yields
+ ------
+ T
+ The items returned from the endpoint.
+
+ Raises
+ ------
+ ValueError
+ If the endpoint is not a callable or an Endpoint object.
+ """
+
+ def __init__(
+ self,
+ endpoint: Union[CallableEndpoint[T], Endpoint[T]],
+ request_opts: Optional[RequestOptions] = None,
+ **kwargs,
+ ) -> None:
+ if isinstance(endpoint, Endpoint):
# The simpliest case is to take an Endpoint and call its get
endpoint = partial(endpoint.get, **kwargs)
self._endpoint = endpoint
- elif callable(endpoint):
+ elif isinstance(endpoint, CallableEndpoint):
# but if they pass a callable then use that instead (used internally)
endpoint = partial(endpoint, **kwargs)
self._endpoint = endpoint
@@ -26,43 +71,24 @@ def __init__(self, endpoint, request_opts=None, **kwargs):
# Didn't get something we can page over
raise ValueError("Pager needs a server endpoint to page through.")
- self._options = request_opts
-
- # If we have options we could be starting on any page, backfill the count
- if self._options:
- self._count = (self._options.pagenumber - 1) * self._options.pagesize
- else:
- self._count = 0
- self._options = RequestOptions()
+ self._options = request_opts or RequestOptions()
- def __iter__(self):
- # Fetch the first page
- current_item_list, last_pagination_item = self._endpoint(self._options)
+ def __iter__(self) -> Iterator[T]:
+ options = copy.deepcopy(self._options)
+ while True:
+ # Fetch the first page
+ current_item_list, pagination_item = self._endpoint(options)
- if last_pagination_item.total_available is None:
- # This endpoint does not support pagination, drain the list and return
- while current_item_list:
- yield current_item_list.pop(0)
-
- return
-
- # Get the rest on demand as a generator
- while self._count < last_pagination_item.total_available:
- if len(current_item_list) == 0:
- current_item_list, last_pagination_item = self._load_next_page(last_pagination_item)
-
- try:
- yield current_item_list.pop(0)
- self._count += 1
+ if pagination_item.total_available is None:
+ # This endpoint does not support pagination, drain the list and return
+ yield from current_item_list
+ return
+ yield from current_item_list
- except IndexError:
- # The total count on Server changed while fetching exit gracefully
+ if pagination_item.page_size * pagination_item.page_number >= pagination_item.total_available:
+ # Last page, exit
return
- def _load_next_page(self, last_pagination_item):
- next_page = last_pagination_item.page_number + 1
- opts = RequestOptions(pagenumber=next_page, pagesize=last_pagination_item.page_size)
- if self._options is not None:
- opts.sort, opts.filter = self._options.sort, self._options.filter
- current_item_list, last_pagination_item = self._endpoint(opts)
- return current_item_list, last_pagination_item
+ # Update the options to fetch the next page
+ options.pagenumber = pagination_item.page_number + 1
+ options.pagesize = pagination_item.page_size
diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py
index 3dbb830fa..5137cee52 100644
--- a/tableauserverclient/server/query.py
+++ b/tableauserverclient/server/query.py
@@ -1,82 +1,266 @@
-from .request_options import RequestOptions
-from .filter import Filter
-from .sort import Sort
+from collections.abc import Iterable, Iterator, Sized
+from itertools import count
+from typing import Optional, Protocol, TYPE_CHECKING, TypeVar, overload
+import sys
+from tableauserverclient.config import config
+from tableauserverclient.models.pagination_item import PaginationItem
+from tableauserverclient.server.endpoint.exceptions import ServerResponseError
+from tableauserverclient.server.filter import Filter
+from tableauserverclient.server.request_options import RequestOptions
+from tableauserverclient.server.sort import Sort
+import math
+from typing_extensions import Self
-def to_camel_case(word):
+if TYPE_CHECKING:
+ from tableauserverclient.server.endpoint import QuerysetEndpoint
+
+T = TypeVar("T")
+
+
+class Slice(Protocol):
+ start: Optional[int]
+ step: Optional[int]
+ stop: Optional[int]
+
+
+def to_camel_case(word: str) -> str:
return word.split("_")[0] + "".join(x.capitalize() or "_" for x in word.split("_")[1:])
-class QuerySet:
- def __init__(self, model):
+"""
+This interface allows more fluent queries against Tableau Server
+e.g server.users.get(name="user@domain.com")
+see pagination_sample
+"""
+
+
+class QuerySet(Iterable[T], Sized):
+ """
+ QuerySet is a class that allows easy filtering, sorting, and iterating over
+ many endpoints in TableauServerClient. It is designed to be used in a similar
+ way to Django QuerySets, but with a more limited feature set.
+
+ QuerySet is an iterable, and can be used in for loops, list comprehensions,
+ and other places where iterables are expected.
+
+ QuerySet is also Sized, and can be used in places where the length of the
+ QuerySet is needed. The length of the QuerySet is the total number of items
+ available in the QuerySet, not just the number of items that have been
+ fetched. If the endpoint does not return a total count of items, the length
+ of the QuerySet will be sys.maxsize. If there is no total count, the
+ QuerySet will continue to fetch items until there are no more items to
+ fetch.
+
+ QuerySet is not re-entrant. It is not designed to be used in multiple places
+ at the same time. If you need to use a QuerySet in multiple places, you
+ should create a new QuerySet for each place you need to use it, convert it
+ to a list, or create a deep copy of the QuerySet.
+
+ QuerySets are also indexable, and can be sliced. If you try to access an
+ index that has not been fetched, the QuerySet will fetch the page that
+ contains the item you are looking for.
+ """
+
+ def __init__(self, model: "QuerysetEndpoint[T]", page_size: Optional[int] = None) -> None:
self.model = model
- self.request_options = RequestOptions()
- self._result_cache = None
- self._pagination_item = None
+ self.request_options = RequestOptions(pagesize=page_size or config.PAGE_SIZE)
+ self._result_cache: list[T] = []
+ self._pagination_item = PaginationItem()
- def __iter__(self):
- self._fetch_all()
- return iter(self._result_cache)
+ def __iter__(self: Self) -> Iterator[T]:
+ # Not built to be re-entrant. Starts back at page 1, and empties
+ # the result cache. Ensure the result_cache is empty to not yield
+ # items from prior usage.
+ self._result_cache = []
+
+ for page in count(1):
+ self.request_options.pagenumber = page
+ self._result_cache = []
+ self._pagination_item._page_number = None
+ try:
+ self._fetch_all()
+ except ServerResponseError as e:
+ if e.code == "400006":
+ # If the endpoint does not support pagination, it will end
+ # up overrunning the total number of pages. Catch the
+ # error and break out of the loop.
+ raise StopIteration
+ if len(self._result_cache) == 0:
+ return
+ yield from self._result_cache
+ # If the length of the QuerySet is unknown, continue fetching until
+ # the result cache is empty.
+ if (size := len(self)) == 0:
+ continue
+ if (page * self.page_size) >= size:
+ return
+
+ @overload
+ def __getitem__(self: Self, k: Slice) -> list[T]: ...
+
+ @overload
+ def __getitem__(self: Self, k: int) -> T: ...
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)
- def _fetch_all(self):
+ # 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 = []
+ self._pagination_item._page_number = None
+ # Add one to k, otherwise it gets stuck at page boundaries, e.g. 100
+ self.request_options.pagenumber = max(1, math.ceil((k + 1) / size))
+ return self[k]
+ else:
+ # If k is unreasonable, raise an IndexError.
+ raise IndexError
+
+ def _fetch_all(self: Self) -> None:
"""
Retrieve the data and store result and pagination item in cache
"""
- if self._result_cache is None:
- self._result_cache, self._pagination_item = self.model.get(self.request_options)
+ if not self._result_cache and self._pagination_item._page_number is None:
+ response = self.model.get(self.request_options)
+ if isinstance(response, tuple):
+ self._result_cache, self._pagination_item = response
+ else:
+ self._result_cache = response
+ self._pagination_item = PaginationItem()
+
+ def __len__(self: Self) -> int:
+ return sys.maxsize if self.total_available is None else self.total_available
@property
- def total_available(self):
+ def total_available(self: Self) -> int:
self._fetch_all()
return self._pagination_item.total_available
@property
- def page_number(self):
+ def page_number(self: Self) -> int:
self._fetch_all()
- return self._pagination_item.page_number
+ # If the PaginationItem is not returned from the endpoint, use the
+ # pagenumber from the RequestOptions.
+ return self._pagination_item.page_number or self.request_options.pagenumber
@property
- def page_size(self):
+ def page_size(self: Self) -> int:
self._fetch_all()
- return self._pagination_item.page_size
+ # If the PaginationItem is not returned from the endpoint, use the
+ # pagesize from the RequestOptions.
+ return self._pagination_item.page_size or self.request_options.pagesize
- def filter(self, **kwargs):
+ def filter(self: Self, *invalid, page_size: Optional[int] = None, **kwargs) -> Self:
+ if invalid:
+ raise RuntimeError("Only accepts keyword arguments.")
for kwarg_key, value in kwargs.items():
field_name, operator = self._parse_shorthand_filter(kwarg_key)
self.request_options.filter.add(Filter(field_name, operator, value))
+
+ if page_size:
+ self.request_options.pagesize = page_size
return self
- def order_by(self, *args):
+ def order_by(self: Self, *args) -> Self:
for arg in args:
field_name, direction = self._parse_shorthand_sort(arg)
self.request_options.sort.add(Sort(field_name, direction))
return self
- def paginate(self, **kwargs):
+ def paginate(self: Self, **kwargs) -> Self:
if "page_number" in kwargs:
self.request_options.pagenumber = kwargs["page_number"]
if "page_size" in kwargs:
self.request_options.pagesize = kwargs["page_size"]
return self
- def _parse_shorthand_filter(self, key):
+ def fields(self: Self, *fields: str) -> Self:
+ """
+ Add fields to the request options. If no fields are provided, the
+ default fields will be used. If fields are provided, the default fields
+ will be used in addition to the provided fields.
+
+ Parameters
+ ----------
+ fields : str
+ The fields to include in the request options.
+
+ Returns
+ -------
+ QuerySet
+ """
+ self.request_options.fields |= set(fields) | set(("_default_"))
+ return self
+
+ def only_fields(self: Self, *fields: str) -> Self:
+ """
+ Add fields to the request options. If no fields are provided, the
+ default fields will be used. If fields are provided, the default fields
+ will be replaced by the provided fields.
+
+ Parameters
+ ----------
+ fields : str
+ The fields to include in the request options.
+
+ Returns
+ -------
+ QuerySet
+ """
+ self.request_options.fields |= set(fields)
+ return self
+
+ @staticmethod
+ def _parse_shorthand_filter(key: str) -> tuple[str, str]:
tokens = key.split("__", 1)
if len(tokens) == 1:
operator = RequestOptions.Operator.Equals
else:
operator = tokens[1]
if operator not in RequestOptions.Operator.__dict__.values():
- raise ValueError("Operator `{}` is not valid.".format(operator))
+ raise ValueError(f"Operator `{operator}` is not valid.")
field = to_camel_case(tokens[0])
if field not in RequestOptions.Field.__dict__.values():
- raise ValueError("Field name `{}` is not valid.".format(field))
+ raise ValueError(f"Field name `{field}` is not valid.")
return (field, operator)
- def _parse_shorthand_sort(self, key):
+ @staticmethod
+ def _parse_shorthand_sort(key: str) -> tuple[str, str]:
direction = RequestOptions.Direction.Asc
if key.startswith("-"):
direction = RequestOptions.Direction.Desc
diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py
index 4cbea1443..c898004f7 100644
--- a/tableauserverclient/server/request_factory.py
+++ b/tableauserverclient/server/request_factory.py
@@ -1,12 +1,22 @@
import xml.etree.ElementTree as ET
+from typing import Any, Callable, Optional, TypeVar, TYPE_CHECKING, Union
+from collections.abc import Iterable
+
+from typing_extensions import ParamSpec
from requests.packages.urllib3.fields import RequestField
from requests.packages.urllib3.filepost import encode_multipart_formdata
+from typing_extensions import Concatenate
+
+from tableauserverclient.models import *
-from ..models import TaskItem, UserItem, GroupItem, PermissionsRule, FavoriteItem
+if TYPE_CHECKING:
+ from tableauserverclient.server import Server
+# this file could be largely replaced if we were willing to import the huge file from generateDS
-def _add_multipart(parts):
+
+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)
@@ -17,8 +27,12 @@ def _add_multipart(parts):
return xml_request, content_type
-def _tsrequest_wrapped(func):
- def wrapper(self, *args, **kwargs):
+T = TypeVar("T")
+P = ParamSpec("P")
+
+
+def _tsrequest_wrapped(func: Callable[Concatenate[T, ET.Element, P], Any]) -> Callable[Concatenate[T, P], bytes]:
+ def wrapper(self: T, *args: P.args, **kwargs: P.kwargs) -> bytes:
xml_request = ET.Element("tsRequest")
func(self, xml_request, *args, **kwargs)
return ET.tostring(xml_request)
@@ -28,11 +42,20 @@ def wrapper(self, *args, **kwargs):
def _add_connections_element(connections_element, connection):
connection_element = ET.SubElement(connections_element, "connection")
+ if not connection.server_address:
+ raise ValueError("Connection must have a server address")
connection_element.attrib["serverAddress"] = connection.server_address
if connection.server_port:
connection_element.attrib["serverPort"] = connection.server_port
if connection.connection_credentials:
connection_credentials = connection.connection_credentials
+ elif connection.username is not None and connection.password is not None and connection.embed_password is not None:
+ connection_credentials = ConnectionCredentials(
+ connection.username, connection.password, embed=connection.embed_password
+ )
+ else:
+ connection_credentials = None
+ if connection_credentials:
_add_credentials_element(connection_element, connection_credentials)
@@ -42,8 +65,15 @@ def _add_hiddenview_element(views_element, view_name):
view_element.attrib["hidden"] = "true"
+def _add_view_element(views_element, view_id):
+ view_element = ET.SubElement(views_element, "view")
+ view_element.attrib["id"] = view_id
+
+
def _add_credentials_element(parent_element, connection_credentials):
credentials_element = ET.SubElement(parent_element, "connectionCredentials")
+ if connection_credentials.password is None or connection_credentials.name is None:
+ raise ValueError("Connection Credentials must have a name and password")
credentials_element.attrib["name"] = connection_credentials.name
credentials_element.attrib["password"] = connection_credentials.password
credentials_element.attrib["embed"] = "true" if connection_credentials.embed else "false"
@@ -51,7 +81,7 @@ def _add_credentials_element(parent_element, connection_credentials):
credentials_element.attrib["oAuth"] = "true"
-class AuthRequest(object):
+class AuthRequest:
def signin_req(self, auth_item):
xml_request = ET.Element("tsRequest")
@@ -75,7 +105,7 @@ def switch_req(self, site_content_url):
return ET.tostring(xml_request)
-class ColumnRequest(object):
+class ColumnRequest:
def update_req(self, column_item):
xml_request = ET.Element("tsRequest")
column_element = ET.SubElement(xml_request, "column")
@@ -86,28 +116,32 @@ def update_req(self, column_item):
return ET.tostring(xml_request)
-class DataAlertRequest(object):
- def add_user_to_alert(self, alert_item, user_id):
+class DataAlertRequest:
+ def add_user_to_alert(self, alert_item: "DataAlertItem", user_id: str) -> bytes:
xml_request = ET.Element("tsRequest")
user_element = ET.SubElement(xml_request, "user")
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)
-class DatabaseRequest(object):
+class DatabaseRequest:
def update_req(self, database_item):
xml_request = ET.Element("tsRequest")
database_element = ET.SubElement(xml_request, "database")
@@ -126,11 +160,12 @@ def update_req(self, database_item):
return ET.tostring(xml_request)
-class DatasourceRequest(object):
- def _generate_xml(self, datasource_item, connection_credentials=None, connections=None):
+class DatasourceRequest:
+ def _generate_xml(self, datasource_item: DatasourceItem, connection_credentials=None, connections=None):
xml_request = ET.Element("tsRequest")
datasource_element = ET.SubElement(xml_request, "datasource")
- datasource_element.attrib["name"] = datasource_item.name
+ if datasource_item.name:
+ datasource_element.attrib["name"] = datasource_item.name
if datasource_item.description:
datasource_element.attrib["description"] = str(datasource_item.description)
if datasource_item.use_remote_query_agent is not None:
@@ -138,18 +173,24 @@ def _generate_xml(self, datasource_item, connection_credentials=None, connection
if datasource_item.ask_data_enablement:
ask_data_element = ET.SubElement(datasource_element, "askData")
- ask_data_element.attrib["enablement"] = datasource_item.ask_data_enablement
+ ask_data_element.attrib["enablement"] = datasource_item.ask_data_enablement.__str__()
- project_element = ET.SubElement(datasource_element, "project")
- project_element.attrib["id"] = datasource_item.project_id
+ if datasource_item.certified:
+ datasource_element.attrib["isCertified"] = datasource_item.certified.__str__()
+ if datasource_item.certification_note:
+ datasource_element.attrib["certificationNote"] = datasource_item.certification_note
+
+ if datasource_item.project_id:
+ project_element = ET.SubElement(datasource_element, "project")
+ project_element.attrib["id"] = datasource_item.project_id
if connection_credentials is not None and connections is not None:
raise RuntimeError("You cannot set both `connections` and `connection_credentials`")
- if connection_credentials is not None:
+ if connection_credentials is not None and connection_credentials != False:
_add_credentials_element(datasource_element, connection_credentials)
- if connections is not None:
+ if connections is not None and connections != False and len(connections) > 0:
connections_element = ET.SubElement(datasource_element, "connections")
for connection in connections:
_add_connections_element(connections_element, connection)
@@ -169,6 +210,8 @@ def update_req(self, datasource_item):
if datasource_item.owner_id:
owner_element = ET.SubElement(datasource_element, "owner")
owner_element.attrib["id"] = datasource_item.owner_id
+ if datasource_item.use_remote_query_agent is not None:
+ datasource_element.attrib["useRemoteQueryAgent"] = str(datasource_item.use_remote_query_agent).lower()
datasource_element.attrib["isCertified"] = str(datasource_item.certified).lower()
@@ -202,7 +245,7 @@ def publish_req_chunked(self, datasource_item, connection_credentials=None, conn
return _add_multipart(parts)
-class DQWRequest(object):
+class DQWRequest:
def add_req(self, dqw_item):
xml_request = ET.Element("tsRequest")
dqw_element = ET.SubElement(xml_request, "dataQualityWarning")
@@ -217,7 +260,7 @@ def add_req(self, dqw_item):
return ET.tostring(xml_request)
- def update_req(self, database_item):
+ def update_req(self, dqw_item):
xml_request = ET.Element("tsRequest")
dqw_element = ET.SubElement(xml_request, "dataQualityWarning")
@@ -232,43 +275,17 @@ 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):
+class FavoriteRequest:
+ def add_request(self, id_: Optional[str], target_type: str, label: Optional[str]) -> bytes:
"""
"""
+ if id_ is None:
+ raise ValueError("Cannot add item as favorite without ID")
+ if label is None:
+ label = target_type
xml_request = ET.Element("tsRequest")
favorite_element = ET.SubElement(xml_request, "favorite")
target = ET.SubElement(favorite_element, target_type)
@@ -277,20 +294,43 @@ def _add_to_req(self, id_, target_type, label):
return ET.tostring(xml_request)
- def add_datasource_req(self, id_, name):
- return self._add_to_req(id_, FavoriteItem.Type.Datasource, name)
-
- def add_project_req(self, id_, name):
- return self._add_to_req(id_, FavoriteItem.Type.Project, name)
-
- def add_view_req(self, id_, name):
- return self._add_to_req(id_, FavoriteItem.Type.View, name)
-
- def add_workbook_req(self, id_, name):
- return self._add_to_req(id_, FavoriteItem.Type.Workbook, name)
-
-
-class FileuploadRequest(object):
+ 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_request(id_, Resource.Datasource, name)
+
+ def add_flow_req(self, id_: Optional[str], name: Optional[str]) -> bytes:
+ if id_ is None:
+ raise ValueError("id must exist to add to favorites")
+ if name is None:
+ raise ValueError("Name must exist to add to favorites.")
+ return self.add_request(id_, Resource.Flow, name)
+
+ def add_project_req(self, id_: Optional[str], name: Optional[str]) -> bytes:
+ if id_ is None:
+ raise ValueError("id must exist to add to favorites")
+ if name is None:
+ raise ValueError("Name must exist to add to favorites.")
+ return self.add_request(id_, Resource.Project, name)
+
+ def add_view_req(self, id_: Optional[str], name: Optional[str]) -> bytes:
+ if id_ is None:
+ raise ValueError("id must exist to add to favorites")
+ if name is None:
+ raise ValueError("Name must exist to add to favorites.")
+ return self.add_request(id_, Resource.View, name)
+
+ def add_workbook_req(self, id_: Optional[str], name: Optional[str]) -> bytes:
+ if id_ is None:
+ raise ValueError("id must exist to add to favorites")
+ if name is None:
+ raise ValueError("Name must exist to add to favorites.")
+ return self.add_request(id_, Resource.Workbook, name)
+
+
+class FileuploadRequest:
def chunk_req(self, chunk):
parts = {
"request_payload": ("", "", "text/xml"),
@@ -299,21 +339,22 @@ def chunk_req(self, chunk):
return _add_multipart(parts)
-class FlowRequest(object):
- def _generate_xml(self, flow_item, connections=None):
+class FlowRequest:
+ def _generate_xml(self, flow_item: "FlowItem", connections: Optional[list["ConnectionItem"]] = None) -> bytes:
xml_request = ET.Element("tsRequest")
flow_element = ET.SubElement(xml_request, "flow")
- 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
- if connections is not None:
+ if connections is not None and connections != False:
connections_element = ET.SubElement(flow_element, "connections")
for connection in connections:
_add_connections_element(connections_element, connection)
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 +366,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,32 +381,60 @@ 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")}
return _add_multipart(parts)
-class GroupRequest(object):
- def add_user_req(self, user_id):
+class GroupRequest:
+ 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):
+ @_tsrequest_wrapped
+ def add_users_req(self, xml_request: ET.Element, users: Iterable[Union[str, UserItem]]) -> bytes:
+ users_element = ET.SubElement(xml_request, "users")
+ for user in users:
+ user_element = ET.SubElement(users_element, "user")
+ if not (user_id := user.id if isinstance(user, UserItem) else user):
+ raise ValueError("User ID must be populated")
+ user_element.attrib["id"] = user_id
+
+ return ET.tostring(xml_request)
+
+ @_tsrequest_wrapped
+ def remove_users_req(self, xml_request: ET.Element, users: Iterable[Union[str, UserItem]]) -> bytes:
+ users_element = ET.SubElement(xml_request, "users")
+ for user in users:
+ user_element = ET.SubElement(users_element, "user")
+ if not (user_id := user.id if isinstance(user, UserItem) else user):
+ raise ValueError("User ID must be populated")
+ user_element.attrib["id"] = user_id
+
+ return ET.tostring(xml_request)
+
+ def create_local_req(self, group_item: GroupItem) -> bytes:
xml_request = ET.Element("tsRequest")
group_element = ET.SubElement(xml_request, "group")
- 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,28 +448,26 @@ 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):
- # (1/8/2021): Deprecated starting v0.15
- if default_site_role is not None:
- import warnings
-
- warnings.simplefilter("always", DeprecationWarning)
- warnings.warn(
- 'RequestFactory.Group.update_req(...default_site_role="") is deprecated, '
- "please set the minimum_site_role field of GroupItem",
- DeprecationWarning,
- )
- group_item.minimum_site_role = default_site_role
-
+ def update_req(
+ self,
+ group_item: GroupItem,
+ ) -> bytes:
xml_request = ET.Element("tsRequest")
group_element = ET.SubElement(xml_request, "group")
- 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:
@@ -405,8 +478,8 @@ def update_req(self, group_item, default_site_role=None):
return ET.tostring(xml_request)
-class PermissionRequest(object):
- def add_req(self, rules):
+class PermissionRequest:
+ def add_req(self, rules: Iterable[PermissionsRule]) -> bytes:
xml_request = ET.Element("tsRequest")
permissions_element = ET.SubElement(xml_request, "permissions")
@@ -427,8 +500,8 @@ def _add_all_capabilities(self, capabilities_element, capabilities_map):
capability_element.attrib["mode"] = mode
-class ProjectRequest(object):
- def update_req(self, project_item):
+class ProjectRequest:
+ def update_req(self, project_item: "ProjectItem") -> bytes:
xml_request = ET.Element("tsRequest")
project_element = ET.SubElement(xml_request, "project")
if project_item.name:
@@ -439,12 +512,16 @@ def update_req(self, project_item):
project_element.attrib["contentPermissions"] = project_item.content_permissions
if project_item.parent_id is not None:
project_element.attrib["parentProjectId"] = project_item.parent_id
+ if (owner := project_item.owner_id) is not None:
+ owner_element = ET.SubElement(project_element, "owner")
+ owner_element.attrib["id"] = owner
return ET.tostring(xml_request)
- def create_req(self, project_item):
+ def create_req(self, project_item: "ProjectItem") -> bytes:
xml_request = ET.Element("tsRequest")
project_element = ET.SubElement(xml_request, "project")
- project_element.attrib["name"] = project_item.name
+ if project_item.name:
+ project_element.attrib["name"] = project_item.name
if project_item.description:
project_element.attrib["description"] = project_item.description
if project_item.content_permissions:
@@ -454,7 +531,7 @@ def create_req(self, project_item):
return ET.tostring(xml_request)
-class ScheduleRequest(object):
+class ScheduleRequest:
def create_req(self, schedule_item):
xml_request = ET.Element("tsRequest")
schedule_element = ET.SubElement(xml_request, "schedule")
@@ -504,7 +581,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 +590,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, received: {type(id_)}")
xml_request = ET.Element("tsRequest")
task_element = ET.SubElement(xml_request, "task")
task = ET.SubElement(task_element, task_type)
@@ -521,15 +600,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):
+class SiteRequest:
+ def update_req(self, site_item: "SiteItem", parent_srv: Optional["Server"] = None):
xml_request = ET.Element("tsRequest")
site_element = ET.SubElement(xml_request, "site")
if site_item.name:
@@ -554,14 +636,15 @@ def update_req(self, site_item):
site_element.attrib["revisionHistoryEnabled"] = str(site_item.revision_history_enabled).lower()
if site_item.data_acceleration_mode is not None:
site_element.attrib["dataAccelerationMode"] = str(site_item.data_acceleration_mode).lower()
- if site_item.flows_enabled is not None:
- site_element.attrib["flowsEnabled"] = str(site_item.flows_enabled).lower()
if site_item.cataloging_enabled is not None:
site_element.attrib["catalogingEnabled"] = str(site_item.cataloging_enabled).lower()
- if site_item.editing_flows_enabled is not None:
- site_element.attrib["editingFlowsEnabled"] = str(site_item.editing_flows_enabled).lower()
- if site_item.scheduling_flows_enabled is not None:
- site_element.attrib["schedulingFlowsEnabled"] = str(site_item.scheduling_flows_enabled).lower()
+
+ flows_edit = str(site_item.editing_flows_enabled).lower()
+ flows_schedule = str(site_item.scheduling_flows_enabled).lower()
+ flows_all = str(site_item.flows_enabled).lower()
+
+ self.set_versioned_flow_attributes(flows_all, flows_edit, flows_schedule, parent_srv, site_element, site_item)
+
if site_item.allow_subscription_attachments is not None:
site_element.attrib["allowSubscriptionAttachments"] = str(site_item.allow_subscription_attachments).lower()
if site_item.guest_access_enabled is not None:
@@ -635,7 +718,8 @@ def update_req(self, site_item):
return ET.tostring(xml_request)
- def create_req(self, site_item):
+ # server: the site request model changes based on api version
+ def create_req(self, site_item: "SiteItem", parent_srv: Optional["Server"] = None):
xml_request = ET.Element("tsRequest")
site_element = ET.SubElement(xml_request, "site")
site_element.attrib["name"] = site_item.name
@@ -654,12 +738,13 @@ def create_req(self, site_item):
site_element.attrib["revisionLimit"] = str(site_item.revision_limit)
if site_item.data_acceleration_mode is not None:
site_element.attrib["dataAccelerationMode"] = str(site_item.data_acceleration_mode).lower()
- if site_item.flows_enabled is not None:
- site_element.attrib["flowsEnabled"] = str(site_item.flows_enabled).lower()
- if site_item.editing_flows_enabled is not None:
- site_element.attrib["editingFlowsEnabled"] = str(site_item.editing_flows_enabled).lower()
- if site_item.scheduling_flows_enabled is not None:
- site_element.attrib["schedulingFlowsEnabled"] = str(site_item.scheduling_flows_enabled).lower()
+
+ flows_edit = str(site_item.editing_flows_enabled).lower()
+ flows_schedule = str(site_item.scheduling_flows_enabled).lower()
+ flows_all = str(site_item.flows_enabled).lower()
+
+ self.set_versioned_flow_attributes(flows_all, flows_edit, flows_schedule, parent_srv, site_element, site_item)
+
if site_item.allow_subscription_attachments is not None:
site_element.attrib["allowSubscriptionAttachments"] = str(site_item.allow_subscription_attachments).lower()
if site_item.guest_access_enabled is not None:
@@ -737,8 +822,34 @@ def create_req(self, site_item):
return ET.tostring(xml_request)
+ def set_versioned_flow_attributes(self, flows_all, flows_edit, flows_schedule, parent_srv, site_element, site_item):
+ if (not parent_srv) or SiteItem.use_new_flow_settings(parent_srv):
+ if site_item.flows_enabled is not None:
+ flows_edit = flows_edit or flows_all
+ flows_schedule = flows_schedule or flows_all
+ import warnings
+
+ warnings.warn(
+ "FlowsEnabled has been removed and become two options:"
+ " SchedulingFlowsEnabled and EditingFlowsEnabled"
+ )
+ if site_item.editing_flows_enabled is not None:
+ site_element.attrib["editingFlowsEnabled"] = flows_edit
+ if site_item.scheduling_flows_enabled is not None:
+ site_element.attrib["schedulingFlowsEnabled"] = flows_schedule
+
+ else:
+ if site_item.flows_enabled is not None:
+ site_element.attrib["flowsEnabled"] = str(site_item.flows_enabled).lower()
+ if site_item.editing_flows_enabled is not None or site_item.scheduling_flows_enabled is not None:
+ flows_all = flows_all or flows_edit or flows_schedule
+ site_element.attrib["flowsEnabled"] = flows_all
+ import warnings
+
+ warnings.warn("In version 3.10 and earlier there is only one option: FlowsEnabled")
+
-class TableRequest(object):
+class TableRequest:
def update_req(self, table_item):
xml_request = ET.Element("tsRequest")
table_element = ET.SubElement(xml_request, "table")
@@ -758,7 +869,10 @@ def update_req(self, table_item):
return ET.tostring(xml_request)
-class TagRequest(object):
+content_types = Iterable[Union["ColumnItem", "DatabaseItem", "DatasourceItem", "FlowItem", "TableItem", "WorkbookItem"]]
+
+
+class TagRequest:
def add_req(self, tag_set):
xml_request = ET.Element("tsRequest")
tags_element = ET.SubElement(xml_request, "tags")
@@ -767,9 +881,25 @@ def add_req(self, tag_set):
tag_element.attrib["label"] = tag
return ET.tostring(xml_request)
+ @_tsrequest_wrapped
+ def batch_create(self, element: ET.Element, tags: set[str], content: content_types) -> bytes:
+ tag_batch = ET.SubElement(element, "tagBatch")
+ tags_element = ET.SubElement(tag_batch, "tags")
+ for tag in tags:
+ tag_element = ET.SubElement(tags_element, "tag")
+ tag_element.attrib["label"] = tag
+ contents_element = ET.SubElement(tag_batch, "contents")
+ for item in content:
+ content_element = ET.SubElement(contents_element, "content")
+ if item.id is None:
+ raise ValueError(f"Item {item} must have an ID to be tagged.")
+ content_element.attrib["id"] = item.id
+
+ return ET.tostring(element)
-class UserRequest(object):
- def update_req(self, user_item, password):
+
+class UserRequest:
+ def update_req(self, user_item: UserItem, password: Optional[str]) -> bytes:
xml_request = ET.Element("tsRequest")
user_element = ET.SubElement(xml_request, "user")
if user_item.fullname:
@@ -783,25 +913,35 @@ def update_req(self, user_item, password):
user_element.attrib["authSetting"] = user_item.auth_setting
if password:
user_element.attrib["password"] = password
+ if user_item.idp_configuration_id is not None:
+ user_element.attrib["idpConfigurationId"] = user_item.idp_configuration_id
return ET.tostring(xml_request)
- def add_req(self, user_item):
+ 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
+
+ if user_item.idp_configuration_id is not None:
+ user_element.attrib["idpConfigurationId"] = user_item.idp_configuration_id
return ET.tostring(xml_request)
-class WorkbookRequest(object):
+class WorkbookRequest:
def _generate_xml(
self,
workbook_item,
- connection_credentials=None,
connections=None,
- hidden_views=None,
):
xml_request = ET.Element("tsRequest")
workbook_element = ET.SubElement(xml_request, "workbook")
@@ -811,25 +951,27 @@ def _generate_xml(
project_element = ET.SubElement(workbook_element, "project")
project_element.attrib["id"] = str(workbook_item.project_id)
- if connection_credentials is not None and connections is not None:
- raise RuntimeError("You cannot set both `connections` and `connection_credentials`")
-
- if connection_credentials is not None:
- _add_credentials_element(workbook_element, connection_credentials)
-
- if connections is not None:
+ if connections is not None and connections != False and len(connections) > 0:
connections_element = ET.SubElement(workbook_element, "connections")
for connection in connections:
_add_connections_element(connections_element, connection)
- if hidden_views is not None:
+ if workbook_item.description is not None:
+ workbook_element.attrib["description"] = workbook_item.description
+
+ 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)
+ if workbook_item.thumbnails_user_id is not None:
+ workbook_element.attrib["thumbnailsUserId"] = workbook_item.thumbnails_user_id
+ elif workbook_item.thumbnails_group_id is not None:
+ workbook_element.attrib["thumbnailsGroupId"] = workbook_item.thumbnails_group_id
+
return ET.tostring(xml_request)
- def update_req(self, workbook_item):
+ def update_req(self, workbook_item, parent_srv: Optional["Server"] = None):
xml_request = ET.Element("tsRequest")
workbook_element = ET.SubElement(xml_request, "workbook")
if workbook_item.name:
@@ -842,16 +984,67 @@ def update_req(self, workbook_item):
if workbook_item.owner_id:
owner_element = ET.SubElement(workbook_element, "owner")
owner_element.attrib["id"] = workbook_item.owner_id
- if workbook_item.data_acceleration_config["acceleration_enabled"] is not None:
+ if (
+ workbook_item.description is not None
+ and parent_srv is not None
+ and parent_srv.check_at_least_version("3.21")
+ ):
+ workbook_element.attrib["description"] = workbook_item.description
+ if workbook_item._views is not None:
+ views_element = ET.SubElement(workbook_element, "views")
+ for view in workbook_item.views:
+ _add_view_element(views_element, view.id)
+ if workbook_item.data_acceleration_config:
data_acceleration_config = workbook_item.data_acceleration_config
data_acceleration_element = ET.SubElement(workbook_element, "dataAccelerationConfig")
- data_acceleration_element.attrib["accelerationEnabled"] = str(
- data_acceleration_config["acceleration_enabled"]
- ).lower()
+ if data_acceleration_config["acceleration_enabled"] is not None:
+ data_acceleration_element.attrib["accelerationEnabled"] = str(
+ data_acceleration_config["acceleration_enabled"]
+ ).lower()
if data_acceleration_config["accelerate_now"] is not None:
data_acceleration_element.attrib["accelerateNow"] = str(
data_acceleration_config["accelerate_now"]
).lower()
+ if workbook_item.data_freshness_policy is not None:
+ data_freshness_policy_config = workbook_item.data_freshness_policy
+ data_freshness_policy_element = ET.SubElement(workbook_element, "dataFreshnessPolicy")
+ data_freshness_policy_element.attrib["option"] = str(data_freshness_policy_config.option)
+ # Fresh Every Schedule
+ if data_freshness_policy_config.option == "FreshEvery":
+ if data_freshness_policy_config.fresh_every_schedule is not None:
+ fresh_every_element = ET.SubElement(data_freshness_policy_element, "freshEverySchedule")
+ fresh_every_element.attrib["frequency"] = (
+ data_freshness_policy_config.fresh_every_schedule.frequency
+ )
+ fresh_every_element.attrib["value"] = str(data_freshness_policy_config.fresh_every_schedule.value)
+ else:
+ raise ValueError(f"data_freshness_policy_config.fresh_every_schedule must be populated.")
+ # Fresh At Schedule
+ if data_freshness_policy_config.option == "FreshAt":
+ if data_freshness_policy_config.fresh_at_schedule is not None:
+ fresh_at_element = ET.SubElement(data_freshness_policy_element, "freshAtSchedule")
+ frequency = data_freshness_policy_config.fresh_at_schedule.frequency
+ fresh_at_element.attrib["frequency"] = frequency
+ fresh_at_element.attrib["time"] = str(data_freshness_policy_config.fresh_at_schedule.time)
+ fresh_at_element.attrib["timezone"] = str(data_freshness_policy_config.fresh_at_schedule.timezone)
+ intervals = data_freshness_policy_config.fresh_at_schedule.interval_item
+ # Fresh At Schedule intervals if Frequency is Week or Month
+ if frequency != DataFreshnessPolicyItem.FreshAt.Frequency.Day:
+ if intervals is not None:
+ # if intervals is not None or frequency != DataFreshnessPolicyItem.FreshAt.Frequency.Day:
+ intervals_element = ET.SubElement(fresh_at_element, "intervals")
+ for interval in intervals:
+ expression = IntervalItem.Occurrence.WeekDay
+ if frequency == DataFreshnessPolicyItem.FreshAt.Frequency.Month:
+ expression = IntervalItem.Occurrence.MonthDay
+ single_interval_element = ET.SubElement(intervals_element, "interval")
+ single_interval_element.attrib[expression] = interval
+ else:
+ raise ValueError(
+ f"fresh_at_schedule.interval_item must be populated for " f"Week & Month frequency."
+ )
+ else:
+ raise ValueError(f"data_freshness_policy_config.fresh_at_schedule must be populated.")
return ET.tostring(xml_request)
@@ -860,15 +1053,11 @@ def publish_req(
workbook_item,
filename,
file_contents,
- connection_credentials=None,
connections=None,
- hidden_views=None,
):
xml_request = self._generate_xml(
workbook_item,
- connection_credentials=connection_credentials,
connections=connections,
- hidden_views=hidden_views,
)
parts = {
@@ -880,57 +1069,142 @@ def publish_req(
def publish_req_chunked(
self,
workbook_item,
- connection_credentials=None,
connections=None,
- hidden_views=None,
):
xml_request = self._generate_xml(
workbook_item,
- connection_credentials=connection_credentials,
connections=connections,
- hidden_views=hidden_views,
)
parts = {"request_payload": ("", xml_request, "text/xml")}
return _add_multipart(parts)
@_tsrequest_wrapped
- def embedded_extract_req(self, xml_request, include_all=True, datasources=None):
+ def embedded_extract_req(
+ self, xml_request: ET.Element, include_all: bool = True, datasources: Optional[Iterable[DatasourceItem]] = None
+ ) -> None:
list_element = ET.SubElement(xml_request, "datasources")
if include_all:
list_element.attrib["includeAll"] = "true"
- else:
+ elif datasources:
for datasource_item in datasources:
- datasource_element = list_element.SubElement(xml_request, "datasource")
- datasource_element.attrib["id"] = datasource_item.id
+ datasource_element = ET.SubElement(list_element, "datasource")
+ if (id_ := datasource_item.id) is not None:
+ datasource_element.attrib["id"] = id_
-class Connection(object):
+class Connection:
@_tsrequest_wrapped
- def update_req(self, xml_request, connection_item):
+ def update_req(self, xml_request: ET.Element, connection_item: "ConnectionItem") -> None:
connection_element = ET.SubElement(xml_request, "connection")
- if connection_item.server_address:
- connection_element.attrib["serverAddress"] = connection_item.server_address.lower()
- if connection_item.server_port:
+ if (server_address := connection_item.server_address) is not None:
+ if (conn_type := connection_item.connection_type) is not None:
+ if conn_type.casefold() != "odata".casefold():
+ server_address = server_address.lower()
+ else:
+ server_address = server_address.lower()
+ connection_element.attrib["serverAddress"] = server_address
+ if connection_item.server_port is not None:
connection_element.attrib["serverPort"] = str(connection_item.server_port)
- if connection_item.username:
+ if connection_item.username is not None:
connection_element.attrib["userName"] = connection_item.username
- if connection_item.password:
+ if connection_item.password is not None:
connection_element.attrib["password"] = connection_item.password
if connection_item.embed_password is not None:
connection_element.attrib["embedPassword"] = str(connection_item.embed_password).lower()
+ if connection_item.query_tagging is not None:
+ connection_element.attrib["queryTaggingEnabled"] = str(connection_item.query_tagging).lower()
-class TaskRequest(object):
+class TaskRequest:
@_tsrequest_wrapped
- def run_req(self, xml_request, task_item):
+ def run_req(self, xml_request: ET.Element, task_item: Any) -> None:
# Send an empty tsRequest
pass
+ @_tsrequest_wrapped
+ def refresh_req(
+ self, xml_request: ET.Element, incremental: bool = False, parent_srv: Optional["Server"] = None
+ ) -> Optional[bytes]:
+ if parent_srv is not None and parent_srv.check_at_least_version("3.25"):
+ task_element = ET.SubElement(xml_request, "extractRefresh")
+ if incremental:
+ task_element.attrib["incremental"] = "true"
+ return ET.tostring(xml_request)
+ elif incremental:
+ raise ValueError("Incremental refresh is only supported in 3.25+")
+ return None
-class SubscriptionRequest(object):
@_tsrequest_wrapped
- def create_req(self, xml_request, subscription_item):
+ def create_extract_req(self, xml_request: ET.Element, extract_item: "TaskItem") -> bytes:
+ extract_element = ET.SubElement(xml_request, "extractRefresh")
+
+ # Main attributes
+ extract_element.attrib["type"] = extract_item.task_type
+
+ if extract_item.target is not None:
+ target_element = ET.SubElement(extract_element, extract_item.target.type)
+ target_element.attrib["id"] = extract_item.target.id
+
+ if extract_item.schedule_item is None:
+ return ET.tostring(xml_request)
+
+ # Schedule attributes
+ schedule_element = ET.SubElement(xml_request, "schedule")
+
+ interval_item = extract_item.schedule_item.interval_item
+ schedule_element.attrib["frequency"] = interval_item._frequency
+ frequency_element = ET.SubElement(schedule_element, "frequencyDetails")
+ frequency_element.attrib["start"] = str(interval_item.start_time)
+ if hasattr(interval_item, "end_time") and interval_item.end_time is not None:
+ frequency_element.attrib["end"] = str(interval_item.end_time)
+ if hasattr(interval_item, "interval") and interval_item.interval:
+ intervals_element = ET.SubElement(frequency_element, "intervals")
+ for interval in interval_item._interval_type_pairs(): # type: ignore
+ expression, value = interval
+ single_interval_element = ET.SubElement(intervals_element, "interval")
+ single_interval_element.attrib[expression] = value
+
+ return ET.tostring(xml_request)
+
+
+class FlowTaskRequest:
+ @_tsrequest_wrapped
+ def create_flow_task_req(self, xml_request: ET.Element, flow_item: "TaskItem") -> bytes:
+ flow_element = ET.SubElement(xml_request, "runFlow")
+
+ # Main attributes
+ flow_element.attrib["type"] = flow_item.task_type
+
+ if flow_item.target is not None:
+ target_element = ET.SubElement(flow_element, flow_item.target.type)
+ target_element.attrib["id"] = flow_item.target.id
+
+ if flow_item.schedule_item is None:
+ return ET.tostring(xml_request)
+
+ # Schedule attributes
+ schedule_element = ET.SubElement(xml_request, "schedule")
+
+ interval_item = flow_item.schedule_item.interval_item
+ schedule_element.attrib["frequency"] = interval_item._frequency
+ frequency_element = ET.SubElement(schedule_element, "frequencyDetails")
+ frequency_element.attrib["start"] = str(interval_item.start_time)
+ if hasattr(interval_item, "end_time") and interval_item.end_time is not None:
+ frequency_element.attrib["end"] = str(interval_item.end_time)
+ if hasattr(interval_item, "interval") and interval_item.interval:
+ intervals_element = ET.SubElement(frequency_element, "intervals")
+ for interval in interval_item._interval_type_pairs(): # type: ignore
+ expression, value = interval
+ single_interval_element = ET.SubElement(intervals_element, "interval")
+ single_interval_element.attrib[expression] = value
+
+ return ET.tostring(xml_request)
+
+
+class SubscriptionRequest:
+ @_tsrequest_wrapped
+ def create_req(self, xml_request: ET.Element, subscription_item: "SubscriptionItem") -> bytes:
subscription_element = ET.SubElement(xml_request, "subscription")
# Main attributes
@@ -963,7 +1237,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
@@ -992,33 +1266,191 @@ def update_req(self, xml_request, subscription_item):
return ET.tostring(xml_request)
-class EmptyRequest(object):
+class EmptyRequest:
@_tsrequest_wrapped
- def empty_req(self, xml_request):
+ def empty_req(self, xml_request: ET.Element) -> None:
pass
-class WebhookRequest(object):
+class WebhookRequest:
@_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)
+
+
+class CustomViewRequest:
+ @_tsrequest_wrapped
+ def update_req(self, xml_request: ET.Element, custom_view_item: CustomViewItem):
+ updating_element = ET.SubElement(xml_request, "customView")
+ if custom_view_item.owner is not None and custom_view_item.owner.id is not None:
+ ET.SubElement(updating_element, "owner", {"id": custom_view_item.owner.id})
+ if custom_view_item.name is not None:
+ updating_element.attrib["name"] = custom_view_item.name
+
+ @_tsrequest_wrapped
+ def _publish_xml(self, xml_request: ET.Element, custom_view_item: CustomViewItem) -> bytes:
+ custom_view_element = ET.SubElement(xml_request, "customView")
+ if (name := custom_view_item.name) is not None:
+ custom_view_element.attrib["name"] = name
+ else:
+ raise ValueError(f"Custom View Item missing name: {custom_view_item}")
+ if (shared := custom_view_item.shared) is not None:
+ custom_view_element.attrib["shared"] = str(shared).lower()
+ else:
+ raise ValueError(f"Custom View Item missing shared: {custom_view_item}")
+ if (owner := custom_view_item.owner) is not None:
+ owner_element = ET.SubElement(custom_view_element, "owner")
+ if (owner_id := owner.id) is not None:
+ owner_element.attrib["id"] = owner_id
+ else:
+ raise ValueError(f"Custom View Item owner missing id: {owner}")
+ else:
+ raise ValueError(f"Custom View Item missing owner: {custom_view_item}")
+ if (workbook := custom_view_item.workbook) is not None:
+ workbook_element = ET.SubElement(custom_view_element, "workbook")
+ if (workbook_id := workbook.id) is not None:
+ workbook_element.attrib["id"] = workbook_id
+ else:
+ raise ValueError(f"Custom View Item workbook missing id: {workbook}")
+ else:
+ raise ValueError(f"Custom View Item missing workbook: {custom_view_item}")
+
+ return ET.tostring(xml_request)
+
+ def publish_req_chunked(self, custom_view_item: CustomViewItem):
+ xml_request = self._publish_xml(custom_view_item)
+ parts = {"request_payload": ("", xml_request, "text/xml")}
+ return _add_multipart(parts)
+
+ def publish_req(self, custom_view_item: CustomViewItem, filename: str, file_contents: bytes):
+ xml_request = self._publish_xml(custom_view_item)
+ parts = {
+ "request_payload": ("", xml_request, "text/xml"),
+ "tableau_customview": (filename, file_contents, "application/octet-stream"),
+ }
+ return _add_multipart(parts)
+
+
+class GroupSetRequest:
+ @_tsrequest_wrapped
+ def create_request(self, xml_request: ET.Element, group_set_item: "GroupSetItem") -> bytes:
+ group_set_element = ET.SubElement(xml_request, "groupSet")
+ if group_set_item.name is not None:
+ group_set_element.attrib["name"] = group_set_item.name
+ return ET.tostring(xml_request)
+
+ @_tsrequest_wrapped
+ def update_request(self, xml_request: ET.Element, group_set_item: "GroupSetItem") -> bytes:
+ group_set_element = ET.SubElement(xml_request, "groupSet")
+ if group_set_item.name is not None:
+ group_set_element.attrib["name"] = group_set_item.name
+ return ET.tostring(xml_request)
+
+
+class VirtualConnectionRequest:
+ @_tsrequest_wrapped
+ def update_db_connection(self, xml_request: ET.Element, connection_item: ConnectionItem) -> bytes:
+ connection_element = ET.SubElement(xml_request, "connection")
+ if connection_item.server_address is not None:
+ connection_element.attrib["serverAddress"] = connection_item.server_address
+ if connection_item.server_port is not None:
+ connection_element.attrib["serverPort"] = str(connection_item.server_port)
+ if connection_item.username is not None:
+ connection_element.attrib["userName"] = connection_item.username
+ if connection_item.password is not None:
+ connection_element.attrib["password"] = connection_item.password
+
+ return ET.tostring(xml_request)
+
+ @_tsrequest_wrapped
+ def update(self, xml_request: ET.Element, virtual_connection: VirtualConnectionItem) -> bytes:
+ vc_element = ET.SubElement(xml_request, "virtualConnection")
+ if virtual_connection.name is not None:
+ vc_element.attrib["name"] = virtual_connection.name
+ if virtual_connection.is_certified is not None:
+ vc_element.attrib["isCertified"] = str(virtual_connection.is_certified).lower()
+ if virtual_connection.certification_note is not None:
+ vc_element.attrib["certificationNote"] = virtual_connection.certification_note
+ if virtual_connection.project_id is not None:
+ project_element = ET.SubElement(vc_element, "project")
+ project_element.attrib["id"] = virtual_connection.project_id
+ if virtual_connection.owner_id is not None:
+ owner_element = ET.SubElement(vc_element, "owner")
+ owner_element.attrib["id"] = virtual_connection.owner_id
+
+ return ET.tostring(xml_request)
+
+ @_tsrequest_wrapped
+ def publish(self, xml_request: ET.Element, virtual_connection: VirtualConnectionItem, content: str) -> bytes:
+ vc_element = ET.SubElement(xml_request, "virtualConnection")
+ if virtual_connection.name is not None:
+ vc_element.attrib["name"] = virtual_connection.name
+ else:
+ raise ValueError("Virtual Connection must have a name.")
+ if virtual_connection.project_id is not None:
+ project_element = ET.SubElement(vc_element, "project")
+ project_element.attrib["id"] = virtual_connection.project_id
+ else:
+ raise ValueError("Virtual Connection must have a project id.")
+ if virtual_connection.owner_id is not None:
+ owner_element = ET.SubElement(vc_element, "owner")
+ owner_element.attrib["id"] = virtual_connection.owner_id
+ else:
+ raise ValueError("Virtual Connection must have an owner id.")
+ if content is not None:
+ content_element = ET.SubElement(vc_element, "content")
+ content_element.text = content
+ else:
+ raise ValueError("Virtual Connection must have content.")
return ET.tostring(xml_request)
-class RequestFactory(object):
+class RequestFactory:
Auth = AuthRequest()
Connection = Connection()
Column = ColumnRequest()
+ CustomView = CustomViewRequest()
DataAlert = DataAlertRequest()
Datasource = DatasourceRequest()
Database = DatabaseRequest()
@@ -1027,7 +1459,10 @@ class RequestFactory(object):
Favorite = FavoriteRequest()
Fileupload = FileuploadRequest()
Flow = FlowRequest()
+ FlowTask = FlowTaskRequest()
Group = GroupRequest()
+ GroupSet = GroupSetRequest()
+ Metric = MetricRequest()
Permission = PermissionRequest()
Project = ProjectRequest()
Schedule = ScheduleRequest()
@@ -1037,5 +1472,6 @@ class RequestFactory(object):
Tag = TagRequest()
Task = TaskRequest()
User = UserRequest()
+ VirtualConnection = VirtualConnectionRequest()
Workbook = WorkbookRequest()
Webhook = WebhookRequest()
diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py
index 3047691a9..4a104255f 100644
--- a/tableauserverclient/server/request_options.py
+++ b/tableauserverclient/server/request_options.py
@@ -1,26 +1,112 @@
-from ..models.property_decorators import property_is_int
+import sys
+from typing import Optional
+import warnings
+from typing_extensions import Self
-class RequestOptionsBase(object):
+from tableauserverclient.config import config
+from tableauserverclient.models.property_decorators import property_is_int
+import logging
+
+from tableauserverclient.helpers.logging import logger
+
+
+class RequestOptionsBase:
# This method is used if server api version is below 3.7 (2020.1)
def apply_query_params(self, url):
try:
params = self.get_query_params()
- params_list = ["{}={}".format(k, v) for (k, v) in params.items()]
+ params_list = [f"{k}={v}" for (k, v) in params.items()]
+
+ logger.debug("Applying options to request: <%s(%s)>", self.__class__.__name__, ",".join(params_list))
if "?" in url:
url, existing_params = url.split("?")
params_list.append(existing_params)
- return "{0}?{1}".format(url, "&".join(params_list))
+ return "{}?{}".format(url, "&".join(params_list))
except NotImplementedError:
raise
- def get_query_params(self):
- raise NotImplementedError()
+
+# If it wasn't a breaking change, I'd rename it to QueryOptions
+"""
+This class manages options can be used when querying content on the server
+"""
class RequestOptions(RequestOptionsBase):
+ """
+ This class is used to manage the options that can be used when querying content on the server.
+ Optionally initialize with a page number and page size to control the number of items returned.
+
+ Additionally, you can add sorting and filtering options to the request.
+
+ The `sort` and `filter` options are set-like objects, so you can only add a field once. If you add the same field
+ multiple times, only the last one will be used.
+
+ The list of fields that can be sorted on or filtered by can be found in the `Field`
+ class contained within this class.
+
+ Parameters
+ ----------
+ pagenumber: int, optional
+ The page number to start the query on. Default is 1.
+
+ pagesize: int, optional
+ The number of items to return per page. Default is 100. Can also read
+ from the environment variable `TSC_PAGE_SIZE`
+ """
+
+ def __init__(self, pagenumber=1, pagesize=None):
+ self.pagenumber = pagenumber
+ self.pagesize = pagesize or config.PAGE_SIZE
+ self.sort = set()
+ self.filter = set()
+ self.fields = set()
+ # This is private until we expand all of our parsers to handle the extra fields
+ self.all_fields = False
+
+ @property
+ def _all_fields(self) -> bool:
+ return self.all_fields
+
+ @_all_fields.setter
+ def _all_fields(self, value):
+ warnings.warn(
+ "Directly setting _all_fields is deprecated, please use the all_fields property instead.",
+ DeprecationWarning,
+ )
+ self.all_fields = value
+
+ def get_query_params(self) -> dict:
+ params = {}
+ if self.sort and len(self.sort) > 0:
+ sort_options = (str(sort_item) for sort_item in self.sort)
+ ordered_sort_options = sorted(sort_options)
+ params["sort"] = ",".join(ordered_sort_options)
+ if len(self.filter) > 0:
+ filter_options = (str(filter_item) for filter_item in self.filter)
+ ordered_filter_options = sorted(filter_options)
+ params["filter"] = ",".join(ordered_filter_options)
+ if self.all_fields:
+ params["fields"] = "_all_"
+ if self.pagenumber:
+ params["pageNumber"] = self.pagenumber
+ if self.pagesize:
+ params["pageSize"] = self.pagesize
+ if self.fields:
+ params["fields"] = ",".join(self.fields)
+ return params
+
+ def page_size(self, page_size):
+ self.pagesize = page_size
+ return self
+
+ def page_number(self, page_number):
+ self.pagenumber = page_number
+ return self
+
class Operator:
Equals = "eq"
GreaterThan = "gt"
@@ -29,145 +115,423 @@ class Operator:
LessThanOrEqual = "lte"
In = "in"
Has = "has"
+ CaseInsensitiveEquals = "cieq"
+ # These are fields in the REST API
class Field:
Args = "args"
+ AuthenticationType = "authenticationType"
+ Caption = "caption"
+ Channel = "channel"
CompletedAt = "completedAt"
+ ConnectedWorkbookType = "connectedWorkbookType"
+ ConnectionTo = "connectionTo"
+ ConnectionType = "connectionType"
+ ContentUrl = "contentUrl"
CreatedAt = "createdAt"
+ DatabaseName = "databaseName"
+ DatabaseUserName = "databaseUserName"
+ Description = "description"
+ DisplayTabs = "displayTabs"
DomainName = "domainName"
DomainNickname = "domainNickname"
+ FavoritesTotal = "favoritesTotal"
+ Fields = "fields"
+ FlowId = "flowId"
+ FriendlyName = "friendlyName"
+ HasAlert = "hasAlert"
+ HasAlerts = "hasAlerts"
+ HasEmbeddedPassword = "hasEmbeddedPassword"
+ HasExtracts = "hasExtracts"
HitsTotal = "hitsTotal"
+ Id = "id"
+ IsCertified = "isCertified"
+ IsConnectable = "isConnectable"
+ IsDefaultPort = "isDefaultPort"
+ IsHierarchical = "isHierarchical"
IsLocal = "isLocal"
+ IsPublished = "isPublished"
JobType = "jobType"
LastLogin = "lastLogin"
+ Luid = "luid"
MinimumSiteRole = "minimumSiteRole"
Name = "name"
Notes = "notes"
+ NotificationType = "notificationType"
OwnerDomain = "ownerDomain"
OwnerEmail = "ownerEmail"
+ OwnerId = "ownerId"
OwnerName = "ownerName"
+ ParentProjectId = "parentProjectId"
+ Priority = "priority"
Progress = "progress"
+ ProjectId = "projectId"
ProjectName = "projectName"
+ PublishSamples = "publishSamples"
+ ServerName = "serverName"
+ ServerPort = "serverPort"
+ SheetCount = "sheetCount"
+ SheetNumber = "sheetNumber"
+ SheetType = "sheetType"
SiteRole = "siteRole"
+ Size = "size"
+ StartedAt = "startedAt"
+ Status = "status"
+ SubscriptionsTotal = "subscriptionsTotal"
Subtitle = "subtitle"
+ TableName = "tableName"
Tags = "tags"
Title = "title"
TopLevelProject = "topLevelProject"
Type = "type"
UpdatedAt = "updatedAt"
UserCount = "userCount"
+ UserId = "userId"
+ ViewId = "viewId"
+ ViewUrlName = "viewUrlName"
+ WorkbookDescription = "workbookDescription"
+ WorkbookId = "workbookId"
+ WorkbookName = "workbookName"
class Direction:
Desc = "desc"
Asc = "asc"
- def __init__(self, pagenumber=1, pagesize=100):
- self.pagenumber = pagenumber
- self.pagesize = pagesize
- self.sort = set()
- self.filter = set()
-
- # This is private until we expand all of our parsers to handle the extra fields
- self._all_fields = False
+ class SelectFields:
+ class Common:
+ All = "_all_"
+ Default = "_default_"
+
+ class ContentsCounts:
+ ProjectCount = "contentsCounts.projectCount"
+ ViewCount = "contentsCounts.viewCount"
+ DatasourceCount = "contentsCounts.datasourceCount"
+ WorkbookCount = "contentsCounts.workbookCount"
+
+ class Datasource:
+ ContentUrl = "datasource.contentUrl"
+ ID = "datasource.id"
+ Name = "datasource.name"
+ Type = "datasource.type"
+ Description = "datasource.description"
+ CreatedAt = "datasource.createdAt"
+ UpdatedAt = "datasource.updatedAt"
+ EncryptExtracts = "datasource.encryptExtracts"
+ IsCertified = "datasource.isCertified"
+ UseRemoteQueryAgent = "datasource.useRemoteQueryAgent"
+ WebPageURL = "datasource.webpageUrl"
+ Size = "datasource.size"
+ Tag = "datasource.tag"
+ FavoritesTotal = "datasource.favoritesTotal"
+ DatabaseName = "datasource.databaseName"
+ ConnectedWorkbooksCount = "datasource.connectedWorkbooksCount"
+ HasAlert = "datasource.hasAlert"
+ HasExtracts = "datasource.hasExtracts"
+ IsPublished = "datasource.isPublished"
+ ServerName = "datasource.serverName"
+
+ class Favorite:
+ Label = "favorite.label"
+ ParentProjectName = "favorite.parentProjectName"
+ TargetOwnerName = "favorite.targetOwnerName"
+
+ class Group:
+ ID = "group.id"
+ Name = "group.name"
+ DomainName = "group.domainName"
+ UserCount = "group.userCount"
+ MinimumSiteRole = "group.minimumSiteRole"
+
+ class Job:
+ ID = "job.id"
+ Status = "job.status"
+ CreatedAt = "job.createdAt"
+ StartedAt = "job.startedAt"
+ EndedAt = "job.endedAt"
+ Priority = "job.priority"
+ JobType = "job.jobType"
+ Title = "job.title"
+ Subtitle = "job.subtitle"
+
+ class Owner:
+ ID = "owner.id"
+ Name = "owner.name"
+ FullName = "owner.fullName"
+ SiteRole = "owner.siteRole"
+ LastLogin = "owner.lastLogin"
+ Email = "owner.email"
+
+ class Project:
+ ID = "project.id"
+ Name = "project.name"
+ Description = "project.description"
+ CreatedAt = "project.createdAt"
+ UpdatedAt = "project.updatedAt"
+ ContentPermissions = "project.contentPermissions"
+ ParentProjectID = "project.parentProjectId"
+ TopLevelProject = "project.topLevelProject"
+ Writeable = "project.writeable"
+
+ class User:
+ ExternalAuthUserId = "user.externalAuthUserId"
+ ID = "user.id"
+ Name = "user.name"
+ SiteRole = "user.siteRole"
+ LastLogin = "user.lastLogin"
+ FullName = "user.fullName"
+ Email = "user.email"
+ AuthSetting = "user.authSetting"
+
+ class View:
+ ID = "view.id"
+ Name = "view.name"
+ ContentUrl = "view.contentUrl"
+ CreatedAt = "view.createdAt"
+ UpdatedAt = "view.updatedAt"
+ Tags = "view.tags"
+ SheetType = "view.sheetType"
+ Usage = "view.usage"
+
+ class Workbook:
+ ID = "workbook.id"
+ Description = "workbook.description"
+ Name = "workbook.name"
+ ContentUrl = "workbook.contentUrl"
+ ShowTabs = "workbook.showTabs"
+ Size = "workbook.size"
+ CreatedAt = "workbook.createdAt"
+ UpdatedAt = "workbook.updatedAt"
+ SheetCount = "workbook.sheetCount"
+ HasExtracts = "workbook.hasExtracts"
+ Tags = "workbook.tags"
+ WebpageUrl = "workbook.webpageUrl"
+ DefaultViewId = "workbook.defaultViewId"
+
+
+"""
+These options can be used by methods that are fetching data exported from a specific content item
+"""
+
+
+class _DataExportOptions(RequestOptionsBase):
+ def __init__(self, maxage: int = -1):
+ super().__init__()
+ self.view_filters: list[tuple[str, str]] = []
+ self.view_parameters: list[tuple[str, str]] = []
+ self.max_age: Optional[int] = maxage
+ """
+ This setting will affect the contents of the workbook as they are exported.
+ Valid language values are tableau-supported languages like de, es, en
+ If no locale is specified, the default locale for that language will be used
+ """
+ self.language: Optional[str] = None
- def page_size(self, page_size):
- self.pagesize = page_size
- return self
+ @property
+ def max_age(self) -> int:
+ return self._max_age
- def page_number(self, page_number):
- self.pagenumber = page_number
- return self
+ @max_age.setter
+ @property_is_int(range=(0, 240), allowed=[-1])
+ def max_age(self, value):
+ self._max_age = value
def get_query_params(self):
params = {}
- if self.pagenumber:
- params["pageNumber"] = self.pagenumber
- if self.pagesize:
- params["pageSize"] = self.pagesize
- if len(self.sort) > 0:
- sort_options = (str(sort_item) for sort_item in self.sort)
- ordered_sort_options = sorted(sort_options)
- params["sort"] = ",".join(ordered_sort_options)
- if len(self.filter) > 0:
- filter_options = (str(filter_item) for filter_item in self.filter)
- ordered_filter_options = sorted(filter_options)
- params["filter"] = ",".join(ordered_filter_options)
- if self._all_fields:
- params["fields"] = "_all_"
+ if self.max_age != -1:
+ params["maxAge"] = self.max_age
+ if self.language:
+ params["language"] = self.language
+
+ self._append_view_filters(params)
return params
+ def vf(self, name: str, value: str) -> Self:
+ """Apply a filter based on a column within the view.
+ Note that when filtering on a boolean type field, the only valid values are 'true' and 'false'
-class _FilterOptionsBase(RequestOptionsBase):
- """Provide a basic implementation of adding view filters to the url"""
+ For more detail see: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_concepts_filtering_and_sorting.htm#Filter-query-views
- def __init__(self):
- self.view_filters = []
+ Parameters
+ ----------
+ name: str
+ The name of the column to filter on
- def get_query_params(self):
- raise NotImplementedError()
+ value: str
+ The value to filter on
- def vf(self, name, value):
+ Returns
+ -------
+ Self
+ The current object
+ """
self.view_filters.append((name, value))
return self
- def _append_view_filters(self, params):
+ def parameter(self, name: str, value: str) -> Self:
+ """Apply a filter based on a parameter within the workbook.
+ Note that when filtering on a boolean type field, the only valid values are 'true' and 'false'
+
+ Parameters
+ ----------
+ name: str
+ The name of the parameter to filter on
+
+ value: str
+ The value to filter on
+
+ Returns
+ -------
+ Self
+ The current object
+ """
+ self.view_parameters.append((name, value))
+ return self
+
+ def _append_view_filters(self, params) -> None:
for name, value in self.view_filters:
params["vf_" + name] = value
+ for name, value in self.view_parameters:
+ params[name] = value
-class CSVRequestOptions(_FilterOptionsBase):
- def __init__(self, maxage=-1):
- super(CSVRequestOptions, self).__init__()
- self.max_age = maxage
+class _ImagePDFCommonExportOptions(_DataExportOptions):
+ def __init__(self, maxage=-1, viz_height=None, viz_width=None):
+ super().__init__(maxage=maxage)
+ self.viz_height = viz_height
+ self.viz_width = viz_width
@property
- def max_age(self):
- return self._max_age
+ def viz_height(self):
+ return self._viz_height
- @max_age.setter
- @property_is_int(range=(0, 240), allowed=[-1])
- def max_age(self, value):
- self._max_age = value
+ @viz_height.setter
+ @property_is_int(range=(0, sys.maxsize), allowed=(None,))
+ def viz_height(self, value):
+ self._viz_height = value
- def get_query_params(self):
- params = {}
- if self.max_age != -1:
- params["maxAge"] = self.max_age
+ @property
+ def viz_width(self):
+ return self._viz_width
+
+ @viz_width.setter
+ @property_is_int(range=(0, sys.maxsize), allowed=(None,))
+ def viz_width(self, value):
+ self._viz_width = value
+
+ def get_query_params(self) -> dict:
+ params = super().get_query_params()
+
+ # XOR. Either both are None or both are not None.
+ if (self.viz_height is None) ^ (self.viz_width is None):
+ raise ValueError("viz_height and viz_width must be specified together")
+
+ if self.viz_height is not None:
+ params["vizHeight"] = self.viz_height
+
+ if self.viz_width is not None:
+ params["vizWidth"] = self.viz_width
- self._append_view_filters(params)
return params
-class ImageRequestOptions(_FilterOptionsBase):
+class CSVRequestOptions(_DataExportOptions):
+ """
+ Options that can be used when exporting a view to CSV. Set the maxage to control the age of the data exported.
+ Filters to the underlying data can be applied using the `vf` and `parameter` methods.
+
+ Parameters
+ ----------
+ maxage: int, optional
+ The maximum age of the data to export. Shortest possible duration is 1
+ minute. No upper limit. Default is -1, which means no limit.
+ """
+
+ extension = "csv"
+
+
+class ExcelRequestOptions(_DataExportOptions):
+ """
+ Options that can be used when exporting a view to Excel. Set the maxage to control the age of the data exported.
+ Filters to the underlying data can be applied using the `vf` and `parameter` methods.
+
+ Parameters
+ ----------
+ maxage: int, optional
+ The maximum age of the data to export. Shortest possible duration is 1
+ minute. No upper limit. Default is -1, which means no limit.
+ """
+
+ extension = "xlsx"
+
+
+class ImageRequestOptions(_ImagePDFCommonExportOptions):
+ """
+ Options that can be used when exporting a view to an image. Set the maxage to control the age of the data exported.
+ Filters to the underlying data can be applied using the `vf` and `parameter` methods.
+
+ Parameters
+ ----------
+ imageresolution: str, optional
+ The resolution of the image to export. Valid values are "high" or None. Default is None.
+ Image width and actual pixel density are determined by the display context
+ of the image. Aspect ratio is always preserved. Set the value to "high" to
+ ensure maximum pixel density.
+
+ maxage: int, optional
+ The maximum age of the data to export. Shortest possible duration is 1
+ minute. No upper limit. Default is -1, which means no limit.
+
+ viz_height: int, optional
+ The height of the viz in pixels. If specified, viz_width must also be specified.
+
+ viz_width: int, optional
+ The width of the viz in pixels. If specified, viz_height must also be specified.
+
+ """
+
+ extension = "png"
+
# if 'high' isn't specified, the REST API endpoint returns an image with standard resolution
class Resolution:
High = "high"
- def __init__(self, imageresolution=None, maxage=-1):
- super(ImageRequestOptions, self).__init__()
+ def __init__(self, imageresolution=None, maxage=-1, viz_height=None, viz_width=None):
+ super().__init__(maxage=maxage, viz_height=viz_height, viz_width=viz_width)
self.image_resolution = imageresolution
- self.max_age = maxage
-
- @property
- def max_age(self):
- return self._max_age
-
- @max_age.setter
- @property_is_int(range=(0, 240), allowed=[-1])
- def max_age(self, value):
- self._max_age = value
def get_query_params(self):
- params = {}
+ params = super().get_query_params()
if self.image_resolution:
params["resolution"] = self.image_resolution
- if self.max_age != -1:
- params["maxAge"] = self.max_age
- self._append_view_filters(params)
return params
-class PDFRequestOptions(_FilterOptionsBase):
+class PDFRequestOptions(_ImagePDFCommonExportOptions):
+ """
+ Options that can be used when exporting a view to PDF. Set the maxage to control the age of the data exported.
+ Filters to the underlying data can be applied using the `vf` and `parameter` methods.
+
+ vf and parameter filters are only supported in API version 3.23 and later.
+
+ Parameters
+ ----------
+ page_type: str, optional
+ The page type of the PDF to export. Valid values are accessible via the `PageType` class.
+
+ orientation: str, optional
+ The orientation of the PDF to export. Valid values are accessible via the `Orientation` class.
+
+ maxage: int, optional
+ The maximum age of the data to export. Shortest possible duration is 1
+ minute. No upper limit. Default is -1, which means no limit.
+
+ viz_height: int, optional
+ The height of the viz in pixels. If specified, viz_width must also be specified.
+
+ viz_width: int, optional
+ The width of the viz in pixels. If specified, viz_height must also be specified.
+ """
+
class PageType:
A3 = "a3"
A4 = "a4"
@@ -188,14 +552,39 @@ class Orientation:
Portrait = "portrait"
Landscape = "landscape"
- def __init__(self, page_type=None, orientation=None, maxage=-1):
- super(PDFRequestOptions, self).__init__()
+ def __init__(self, page_type=None, orientation=None, maxage=-1, viz_height=None, viz_width=None):
+ super().__init__(maxage=maxage, viz_height=viz_height, viz_width=viz_width)
self.page_type = page_type
self.orientation = orientation
+
+ def get_query_params(self) -> dict:
+ params = super().get_query_params()
+ if self.page_type:
+ params["type"] = self.page_type
+
+ if self.orientation:
+ params["orientation"] = self.orientation
+
+ return params
+
+
+class PPTXRequestOptions(RequestOptionsBase):
+ """
+ Options that can be used when exporting a view to PPTX. Set the maxage to control the age of the data exported.
+
+ Parameters
+ ----------
+ maxage: int, optional
+ The maximum age of the data to export. Shortest possible duration is 1
+ minute. No upper limit. Default is -1, which means no limit.
+ """
+
+ def __init__(self, maxage=-1):
+ super().__init__()
self.max_age = maxage
@property
- def max_age(self):
+ def max_age(self) -> int:
return self._max_age
@max_age.setter
@@ -205,15 +594,7 @@ def max_age(self, value):
def get_query_params(self):
params = {}
- if self.page_type:
- params["type"] = self.page_type
-
- if self.orientation:
- params["orientation"] = self.orientation
-
if self.max_age != -1:
params["maxAge"] = self.max_age
- self._append_view_filters(params)
-
return params
diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py
index 56fc47849..d5d163db3 100644
--- a/tableauserverclient/server/server.py
+++ b/tableauserverclient/server/server.py
@@ -1,8 +1,12 @@
-import xml.etree.ElementTree as ET
+from tableauserverclient.helpers.logging import logger
-from .exceptions import NotSignedInError
-from ..namespace import Namespace
-from .endpoint import (
+import requests
+import urllib3
+import ssl
+
+from defusedxml.ElementTree import fromstring, ParseError
+from packaging.version import Version
+from tableauserverclient.server.endpoint import (
Sites,
Views,
Users,
@@ -20,24 +24,28 @@
Databases,
Tables,
Flows,
+ FlowTasks,
Webhooks,
DataAccelerationReport,
Favorites,
DataAlerts,
Fileuploads,
- FlowRuns
+ FlowRuns,
+ Metrics,
+ Endpoint,
+ CustomViews,
+ LinkedTasks,
+ GroupSets,
+ Tags,
+ VirtualConnections,
)
-from .endpoint.exceptions import (
- EndpointUnavailableError,
+from tableauserverclient.server.exceptions import (
ServerInfoEndpointNotFoundError,
+ EndpointUnavailableError,
)
+from tableauserverclient.server.endpoint.exceptions import NotSignedInError
+from tableauserverclient.namespace import Namespace
-import requests
-
-try:
- from distutils2.version import NormalizedVersion as Version
-except ImportError:
- from distutils.version import LooseVersion as Version
_PRODUCT_TO_REST_VERSION = {
"10.0": "2.3",
@@ -47,22 +55,103 @@
"9.0": "2.0",
}
+minimum_supported_server_version = "2.3"
+default_server_version = "2.4" # first version that dropped the legacy auth endpoint
+
+
+class Server:
+ """
+ In the Tableau REST API, the server (https://MY-SERVER/) is the base or core
+ of the URI that makes up the various endpoints or methods for accessing
+ resources on the server (views, workbooks, sites, users, data sources, etc.)
+ The TSC library provides a Server class that represents the server. You
+ create a server instance to sign in to the server and to call the various
+ methods for accessing resources.
+
+ The Server class contains the attributes that represent the server on
+ Tableau Server. After you create an instance of the Server class, you can
+ sign in to the server and call methods to access all of the resources on the
+ server.
+
+ Parameters
+ ----------
+ server_address : str
+ Specifies the address of the Tableau Server or Tableau Cloud (for
+ example, https://MY-SERVER/).
+
+ use_server_version : bool
+ Specifies the version of the REST API to use (for example, '2.5'). When
+ you use the TSC library to call methods that access Tableau Server, the
+ version is passed to the endpoint as part of the URI
+ (https://MY-SERVER/api/2.5/). Each release of Tableau Server supports
+ specific versions of the REST API. New versions of the REST API are
+ released with Tableau Server. By default, the value of version is set to
+ '2.3', which corresponds to Tableau Server 10.0. You can view or set
+ this value. You might need to set this to a different value, for
+ example, if you want to access features that are supported by the server
+ and a later version of the REST API. For more information, see REST API
+ Versions.
+
+ http_options : dict, optional
+ Additional options to pass to the requests library when making HTTP requests.
+
+ session_factory : callable, optional
+ A factory function that returns a requests.Session object. If not provided,
+ requests.session is used.
+
+ Examples
+ --------
+ >>> import tableauserverclient as TSC
+
+ >>> # create a instance of server
+ >>> server = TSC.Server('https://MY-SERVER')
+
+ >>> # sign in, etc.
+
+ >>> # change the REST API version to match the server
+ >>> server.use_server_version()
+
+ >>> # or change the REST API version to match a specific version
+ >>> # for example, 2.8
+ >>> # server.version = '2.8'
+
+ >>> # if connecting to an older Tableau Server with weak DH keys (Python 3.12+ only)
+ >>> server.configure_ssl(allow_weak_dh=True) # Note: reduces security
+
+ Notes
+ -----
+ When using Python 3.12 or later with older versions of Tableau Server, you may encounter
+ SSL errors related to weak Diffie-Hellman keys. This is because newer Python versions
+ enforce stronger security requirements. You can temporarily work around this using
+ configure_ssl(allow_weak_dh=True), but this reduces security and should only be used
+ as a temporary measure until the server can be upgraded.
+ """
-class Server(object):
class PublishMode:
+ """
+ Enumerates the options that specify what happens when you publish a
+ workbook or data source. The options are Overwrite, Append, or
+ CreateNew.
+ """
+
Append = "Append"
Overwrite = "Overwrite"
CreateNew = "CreateNew"
+ Replace = "Replace"
- def __init__(self, server_address, use_server_version=False):
- self._server_address = server_address
+ def __init__(self, server_address, use_server_version=False, http_options=None, session_factory=None):
self._auth_token = None
self._site_id = None
self._user_id = None
- self._session = requests.Session()
- self._http_options = dict()
+ self._ssl_context = None
+
+ # TODO: this needs to change to default to https, but without breaking existing code
+ if not server_address.startswith("http://") and not server_address.startswith("https://"):
+ server_address = "http://" + server_address
+
+ self._server_address: str = server_address
+ self._session_factory = session_factory or requests.session
- self.version = "2.3"
self.auth = Auth(self)
self.views = Views(self)
self.users = Users(self)
@@ -73,6 +162,7 @@ def __init__(self, server_address, use_server_version=False):
self.datasources = Datasources(self)
self.favorites = Favorites(self)
self.flows = Flows(self)
+ self.flow_tasks = FlowTasks(self)
self.projects = Projects(self)
self.schedules = Schedules(self)
self.server_info = ServerInfo(self)
@@ -87,12 +177,48 @@ 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)
+ self.custom_views = CustomViews(self)
+ self.linked_tasks = LinkedTasks(self)
+ self.group_sets = GroupSets(self)
+ self.tags = Tags(self)
+ self.virtual_connections = VirtualConnections(self)
+ self._session = self._session_factory()
+ self._http_options = dict() # must set this before making a server call
+ if http_options:
+ self.add_http_options(http_options)
+
+ self.validate_connection_settings() # does not make an actual outgoing request
+
+ self.version = default_server_version
if use_server_version:
- self.use_server_version()
+ self.use_server_version() # this makes a server call
- def add_http_options(self, options_dict):
- self._http_options.update(options_dict)
+ def validate_connection_settings(self):
+ try:
+ params = Endpoint(self).set_parameters(self._http_options, None, None, None, None)
+ Endpoint.set_user_agent(params)
+ if not self._server_address.startswith("http://") and not self._server_address.startswith("https://"):
+ self._server_address = "http://" + self._server_address
+ self._session.prepare_request(requests.Request("GET", url=self._server_address, params=self._http_options))
+ except Exception as req_ex:
+ raise ValueError("Server connection settings not valid", req_ex)
+
+ def __repr__(self):
+ return f""
+
+ def add_http_options(self, options_dict: dict):
+ try:
+ self._http_options.update(options_dict)
+ if "verify" in options_dict.keys() and self._http_options.get("verify") is False:
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+ # would be nice if you could turn them back on
+ except Exception as be:
+ # expected errors on invalid input:
+ # 'set' object has no attribute 'keys', 'list' object has no attribute 'keys'
+ # TypeError: cannot convert dictionary update sequence element #0 to a sequence (input is a tuple)
+ raise ValueError(f"Invalid http options given: {options_dict}")
def clear_http_options(self):
self._http_options = dict()
@@ -101,54 +227,64 @@ def _clear_auth(self):
self._site_id = None
self._user_id = None
self._auth_token = None
- self._session = requests.Session()
+ self._site_url = None
+ self._session = self._session_factory()
- def _set_auth(self, site_id, user_id, auth_token):
+ def _set_auth(self, site_id, user_id, auth_token, site_url=None):
self._site_id = site_id
self._user_id = user_id
self._auth_token = auth_token
+ self._site_url = site_url
def _get_legacy_version(self):
+ # the serverInfo call was introduced in 2.4, earlier than that we have this different call
response = self._session.get(self.server_address + "/auth?format=xml")
- info_xml = ET.fromstring(response.content)
+ try:
+ info_xml = fromstring(response.content)
+ except ParseError as parseError:
+ logger.info(parseError)
+ logger.info("Could not read server version info. The server may not be running or configured.")
+ return self.version
prod_version = info_xml.find(".//product_version").text
- version = _PRODUCT_TO_REST_VERSION.get(prod_version, "2.1") # 2.1
+ version = _PRODUCT_TO_REST_VERSION.get(prod_version, minimum_supported_server_version)
return version
def _determine_highest_version(self):
try:
old_version = self.version
- self.version = "2.4"
version = self.server_info.get().rest_api_version
- except ServerInfoEndpointNotFoundError:
+ except ServerInfoEndpointNotFoundError as e:
+ logger.info(f"Could not get version info from server: {e.__class__}{e}")
version = self._get_legacy_version()
-
- finally:
- self.version = old_version
-
- return version
+ except EndpointUnavailableError as e:
+ logger.info(f"Could not get version info from server: {e.__class__}{e}")
+ version = self._get_legacy_version()
+ except Exception as e:
+ logger.info(f"Could not get version info from server: {e.__class__}{e}")
+ version = None
+ logger.info(f"versions: {version}, {old_version}")
+ return version or old_version
def use_server_version(self):
self.version = self._determine_highest_version()
def use_highest_version(self):
self.use_server_version()
- import warnings
+ logger.info("use use_server_version instead", DeprecationWarning)
- warnings.warn("use use_server_version instead", DeprecationWarning)
+ def check_at_least_version(self, target: str):
+ server_version = Version(self.version or "2.4")
+ target_version = Version(target)
+ return server_version >= target_version
- def assert_at_least_version(self, version):
- server_version = Version(self.version or "0.0")
- minimum_supported = Version(version)
- if server_version < minimum_supported:
- error = "This endpoint is not available in API version {}. Requires {}".format(
- server_version, minimum_supported
- )
+ def assert_at_least_version(self, comparison: str, reason: str):
+ if not self.check_at_least_version(comparison):
+ error = f"{reason} is not available in API version {self.version}. Requires {comparison}"
raise EndpointUnavailableError(error)
@property
def baseurl(self):
- return "{0}/api/{1}".format(self._server_address, str(self.version))
+ return f"{self._server_address}/api/{str(self.version)}"
@property
def namespace(self):
@@ -168,6 +304,13 @@ def site_id(self):
raise NotSignedInError(error)
return self._site_id
+ @property
+ def site_url(self):
+ if self._site_url is None:
+ error = "Missing site URL. You must sign in first."
+ raise NotSignedInError(error)
+ return self._site_url
+
@property
def user_id(self):
if self._user_id is None:
@@ -189,3 +332,26 @@ def session(self):
def is_signed_in(self):
return self._auth_token is not None
+
+ def configure_ssl(self, *, allow_weak_dh=False):
+ """Configure SSL/TLS settings for the server connection.
+
+ Parameters
+ ----------
+ allow_weak_dh : bool, optional
+ If True, allows connections to servers with DH keys that are considered too small by modern Python versions.
+ WARNING: This reduces security and should only be used as a temporary workaround.
+ """
+ if allow_weak_dh:
+ logger.warning(
+ "WARNING: Allowing weak Diffie-Hellman keys. This reduces security and should only be used temporarily."
+ )
+ self._ssl_context = ssl.create_default_context()
+ # Allow weak DH keys by setting minimum key size to 512 bits (default is 1024 in Python 3.12+)
+ self._ssl_context.set_dh_parameters(min_key_bits=512)
+ self.add_http_options({"verify": self._ssl_context})
+ else:
+ self._ssl_context = None
+ # Remove any custom SSL context if we're reverting to default settings
+ if "verify" in self._http_options:
+ del self._http_options["verify"]
diff --git a/tableauserverclient/server/sort.py b/tableauserverclient/server/sort.py
index 2d6bc030a..b78645921 100644
--- a/tableauserverclient/server/sort.py
+++ b/tableauserverclient/server/sort.py
@@ -1,7 +1,21 @@
-class Sort(object):
+class Sort:
+ """
+ Used with request options (RequestOptions) where you can filter and sort on
+ the results returned from the server.
+
+ Parameters
+ ----------
+ field : str
+ Sets the field to sort on. The fields are defined in the RequestOption class.
+
+ direction : str
+ The direction to sort, either ascending (Asc) or descending (Desc). The
+ options are defined in the RequestOptions.Direction class.
+ """
+
def __init__(self, field, direction):
self.field = field
self.direction = direction
def __str__(self):
- return "{0}:{1}".format(self.field, self.direction)
+ return f"{self.field}:{self.direction}"
diff --git a/test/_utils.py b/test/_utils.py
index 626838f23..b4ee93bc3 100644
--- a/test/_utils.py
+++ b/test/_utils.py
@@ -1,8 +1,9 @@
-from contextlib import contextmanager
-import unittest
import os.path
+import unittest
+from xml.etree import ElementTree as ET
+from contextlib import contextmanager
-TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets')
+TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets")
def asset(filename):
@@ -10,14 +11,27 @@ 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):
return map(read_xml_asset, args)
+def server_response_error_factory(code: str, summary: str, detail: str) -> str:
+ root = ET.Element("tsResponse")
+ error = ET.SubElement(root, "error")
+ error.attrib["code"] = code
+
+ summary_element = ET.SubElement(error, "summary")
+ summary_element.text = summary
+
+ detail_element = ET.SubElement(error, "detail")
+ detail_element.text = detail
+ return ET.tostring(root, encoding="utf-8").decode("utf-8")
+
+
@contextmanager
def mocked_time():
mock_time = 0
@@ -28,7 +42,7 @@ def sleep_mock(interval):
def get_time():
return mock_time
-
+
try:
patch = unittest.mock.patch
except AttributeError:
diff --git a/test/assets/Data/user_details.csv b/test/assets/Data/user_details.csv
new file mode 100644
index 000000000..15b975942
--- /dev/null
+++ b/test/assets/Data/user_details.csv
@@ -0,0 +1 @@
+username, pword, , yes, email
diff --git a/test/assets/Data/usernames.csv b/test/assets/Data/usernames.csv
new file mode 100644
index 000000000..0350c0dd6
--- /dev/null
+++ b/test/assets/Data/usernames.csv
@@ -0,0 +1,7 @@
+valid,
+valid@email.com,
+domain/valid,
+domain/valid@tmail.com,
+va!@#$%^&*()lid,
+in@v@lid,
+in valid,
diff --git a/test/assets/SampleFlow.tfl b/test/assets/SampleFlow.tfl
new file mode 100644
index 000000000..c46d9ced9
Binary files /dev/null and b/test/assets/SampleFlow.tfl differ
diff --git a/test/assets/custom_view_download.json b/test/assets/custom_view_download.json
new file mode 100644
index 000000000..1ba2d74b7
--- /dev/null
+++ b/test/assets/custom_view_download.json
@@ -0,0 +1,47 @@
+[
+ {
+ "isSourceView": true,
+ "viewName": "Overview",
+ "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IGRhc2hib2FyZD0nT3ZlcnZpZXcnIHNvdXJjZS1idWlsZD0nMjAyNC4yLjAgKDIwMjQyLjI0LjA3MTYuMTk0NCknIHZlcnNpb249JzE4LjEnIHhtbG5zOnVzZXI9J2h0dHA6Ly93d3cudGFibGVhdXNvZnR3YXJlLmNvbS94bWwvdXNlcic-CiAgPGFjdGl2ZSBpZD0nMScgLz4KICA8ZGF0YXNvdXJjZXM-CiAgICA8ZGF0YXNvdXJjZSBuYW1lPSdmZWRlcmF0ZWQuMTBubms4ZDF2Z213OHExN3l1NzZ1MDZwbmJjaic-CiAgICAgIDxjb2x1bW4gZGF0YXR5cGU9J3N0cmluZycgbmFtZT0nWzpNZWFzdXJlIE5hbWVzXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJz4KICAgICAgICA8YWxpYXNlcz4KICAgICAgICAgIDxhbGlhcyBrZXk9JyZxdW90O1tmZWRlcmF0ZWQuMTBubms4ZDF2Z213OHExN3l1NzZ1MDZwbmJjal0uW2N0ZDpDdXN0b21lciBOYW1lOnFrXSZxdW90OycgdmFsdWU9J0NvdW50IG9mIEN1c3RvbWVycycgLz4KICAgICAgICA8L2FsaWFzZXM-CiAgICAgIDwvY29sdW1uPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChNT05USChPcmRlciBEYXRlKSxQcm9kdWN0IENhdGVnb3J5KScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChNT05USChPcmRlciBEYXRlKSxQcm9kdWN0IENhdGVnb3J5KV0nIG5hbWUtc3R5bGU9J3VucXVhbGlmaWVkJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJz4KICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2Nyb3Nzam9pbic-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbdG1uOk9yZGVyIERhdGU6b2tdJyAvPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW25vbmU6Q2F0ZWdvcnk6bmtdJyAvPgogICAgICAgIDwvZ3JvdXBmaWx0ZXI-CiAgICAgIDwvZ3JvdXA-CiAgICAgIDxjb2x1bW4gY2FwdGlvbj0nQWN0aW9uIChNT05USChPcmRlciBEYXRlKSxQcm9kdWN0IENhdGVnb3J5KScgZGF0YXR5cGU9J3R1cGxlJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKE1PTlRIKE9yZGVyIERhdGUpLFByb2R1Y3QgQ2F0ZWdvcnkpXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJyAvPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChNT05USChPcmRlciBEYXRlKSxTZWdtZW50KScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChNT05USChPcmRlciBEYXRlKSxTZWdtZW50KV0nIG5hbWUtc3R5bGU9J3VucXVhbGlmaWVkJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJz4KICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2Nyb3Nzam9pbic-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbdG1uOk9yZGVyIERhdGU6b2tdJyAvPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW1NlZ21lbnRdJyAvPgogICAgICAgIDwvZ3JvdXBmaWx0ZXI-CiAgICAgIDwvZ3JvdXA-CiAgICAgIDxjb2x1bW4gY2FwdGlvbj0nQWN0aW9uIChNT05USChPcmRlciBEYXRlKSxTZWdtZW50KScgZGF0YXR5cGU9J3R1cGxlJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKE1PTlRIKE9yZGVyIERhdGUpLFNlZ21lbnQpXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJyAvPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChPcmRlciBQcm9maXRhYmxlPyxDYXRlZ29yeSxNT05USChPcmRlciBEYXRlKSknIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoT3JkZXIgUHJvZml0YWJsZT8sQ2F0ZWdvcnksTU9OVEgoT3JkZXIgRGF0ZSkpXScgbmFtZS1zdHlsZT0ndW5xdWFsaWZpZWQnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnPgogICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nY3Jvc3Nqb2luJz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1tDYWxjdWxhdGlvbl85MDYwMTIyMTA0OTQ3NDcxXScgLz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1tDYXRlZ29yeV0nIC8-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbdG1uOk9yZGVyIERhdGU6b2tdJyAvPgogICAgICAgIDwvZ3JvdXBmaWx0ZXI-CiAgICAgIDwvZ3JvdXA-CiAgICAgIDxjb2x1bW4gY2FwdGlvbj0nQWN0aW9uIChPcmRlciBQcm9maXRhYmxlPyxDYXRlZ29yeSxNT05USChPcmRlciBEYXRlKSknIGRhdGF0eXBlPSd0dXBsZScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChPcmRlciBQcm9maXRhYmxlPyxDYXRlZ29yeSxNT05USChPcmRlciBEYXRlKSldJyByb2xlPSdkaW1lbnNpb24nIHR5cGU9J25vbWluYWwnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnIC8-CiAgICAgIDxncm91cCBjYXB0aW9uPSdBY3Rpb24gKE9yZGVyIFByb2ZpdGFibGU_LE1PTlRIKE9yZGVyIERhdGUpLFNlZ21lbnQpJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKE9yZGVyIFByb2ZpdGFibGU_LE1PTlRIKE9yZGVyIERhdGUpLFNlZ21lbnQpXScgbmFtZS1zdHlsZT0ndW5xdWFsaWZpZWQnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnPgogICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nY3Jvc3Nqb2luJz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1tDYWxjdWxhdGlvbl85MDYwMTIyMTA0OTQ3NDcxXScgLz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1t0bW46T3JkZXIgRGF0ZTpva10nIC8-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbU2VnbWVudF0nIC8-CiAgICAgICAgPC9ncm91cGZpbHRlcj4KICAgICAgPC9ncm91cD4KICAgICAgPGNvbHVtbiBjYXB0aW9uPSdBY3Rpb24gKE9yZGVyIFByb2ZpdGFibGU_LE1PTlRIKE9yZGVyIERhdGUpLFNlZ21lbnQpJyBkYXRhdHlwZT0ndHVwbGUnIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoT3JkZXIgUHJvZml0YWJsZT8sTU9OVEgoT3JkZXIgRGF0ZSksU2VnbWVudCldJyByb2xlPSdkaW1lbnNpb24nIHR5cGU9J25vbWluYWwnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnIC8-CiAgICAgIDxncm91cCBjYXB0aW9uPSdBY3Rpb24gKFBvc3RhbCBDb2RlLFN0YXRlL1Byb3ZpbmNlKScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChQb3N0YWwgQ29kZSxTdGF0ZS9Qcm92aW5jZSldJyBuYW1lLXN0eWxlPSd1bnF1YWxpZmllZCcgdXNlcjphdXRvLWNvbHVtbj0nc2hlZXRfbGluayc-CiAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdjcm9zc2pvaW4nPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW25vbmU6UG9zdGFsIENvZGU6bmtdJyAvPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW1N0YXRlL1Byb3ZpbmNlXScgLz4KICAgICAgICA8L2dyb3VwZmlsdGVyPgogICAgICA8L2dyb3VwPgogICAgICA8Y29sdW1uIGNhcHRpb249J0FjdGlvbiAoUG9zdGFsIENvZGUsU3RhdGUvUHJvdmluY2UpJyBkYXRhdHlwZT0ndHVwbGUnIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoUG9zdGFsIENvZGUsU3RhdGUvUHJvdmluY2UpXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJyAvPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChTdGF0ZS9Qcm92aW5jZSknIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoU3RhdGUvUHJvdmluY2UpXScgbmFtZS1zdHlsZT0ndW5xdWFsaWZpZWQnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnPgogICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nY3Jvc3Nqb2luJz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1tTdGF0ZS9Qcm92aW5jZV0nIC8-CiAgICAgICAgPC9ncm91cGZpbHRlcj4KICAgICAgPC9ncm91cD4KICAgICAgPGNvbHVtbiBjYXB0aW9uPSdBY3Rpb24gKFN0YXRlL1Byb3ZpbmNlKScgZGF0YXR5cGU9J3R1cGxlJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKFN0YXRlL1Byb3ZpbmNlKV0nIHJvbGU9J2RpbWVuc2lvbicgdHlwZT0nbm9taW5hbCcgdXNlcjphdXRvLWNvbHVtbj0nc2hlZXRfbGluaycgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tDYWxjdWxhdGlvbl85MDYwMTIyMTA0OTQ3NDcxXScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6Q2FsY3VsYXRpb25fOTA2MDEyMjEwNDk0NzQ3MTpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbT3JkZXIgRGF0ZV0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOk9yZGVyIERhdGU6cWtdJyBwaXZvdD0na2V5JyB0eXBlPSdxdWFudGl0YXRpdmUnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbUmVnaW9uXScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6UmVnaW9uOm5rXScgcGl2b3Q9J2tleScgdHlwZT0nbm9taW5hbCcgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tDYWxjdWxhdGlvbl85OTIxMTAzMTQ0MTAzNzQzXScgZGVyaXZhdGlvbj0nVXNlcicgbmFtZT0nW3VzcjpDYWxjdWxhdGlvbl85OTIxMTAzMTQ0MTAzNzQzOnFrXScgcGl2b3Q9J2tleScgdHlwZT0ncXVhbnRpdGF0aXZlJyAvPgogICAgPC9kYXRhc291cmNlPgogIDwvZGF0YXNvdXJjZXM-CiAgPHdvcmtzaGVldCBuYW1lPSdUb3RhbCBTYWxlcyc-CiAgICA8ZmlsdGVyIGNsYXNzPSdjYXRlZ29yaWNhbCcgY29sdW1uPSdbZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLltBY3Rpb24gKFN0YXRlL1Byb3ZpbmNlKV0nPgogICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J21lbWJlcicgbGV2ZWw9J1tTdGF0ZS9Qcm92aW5jZV0nIG1lbWJlcj0nJnF1b3Q7VGV4YXMmcXVvdDsnIHVzZXI6dWktYWN0aW9uLWZpbHRlcj0nW0FjdGlvbjFdJyB1c2VyOnVpLWRvbWFpbj0nZGF0YWJhc2UnIHVzZXI6dWktZW51bWVyYXRpb249J2luY2x1c2l2ZScgdXNlcjp1aS1tYXJrZXI9J2VudW1lcmF0ZScgLz4KICAgIDwvZmlsdGVyPgogICAgPGZpbHRlciBjbGFzcz0nY2F0ZWdvcmljYWwnIGNvbHVtbj0nW2ZlZGVyYXRlZC4xMG5uazhkMXZnbXc4cTE3eXU3NnUwNnBuYmNqXS5bbm9uZTpSZWdpb246bmtdJyBmaWx0ZXItZ3JvdXA9JzE0Jz4KICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdtZW1iZXInIGxldmVsPSdbbm9uZTpSZWdpb246bmtdJyBtZW1iZXI9JyZxdW90O0NlbnRyYWwmcXVvdDsnIHVzZXI6dWktZG9tYWluPSdkYXRhYmFzZScgdXNlcjp1aS1lbnVtZXJhdGlvbj0naW5jbHVzaXZlJyB1c2VyOnVpLW1hcmtlcj0nZW51bWVyYXRlJyAvPgogICAgPC9maWx0ZXI-CiAgICA8dGFibGUgLz4KICA8L3dvcmtzaGVldD4KICA8d29ya3NoZWV0IG5hbWU9J1NhbGUgTWFwJz4KICAgIDxmaWx0ZXIgY2xhc3M9J2NhdGVnb3JpY2FsJyBjb2x1bW49J1tmZWRlcmF0ZWQuMTBubms4ZDF2Z213OHExN3l1NzZ1MDZwbmJjal0uW25vbmU6UmVnaW9uOm5rXScgZmlsdGVyLWdyb3VwPScxNCc-CiAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbWVtYmVyJyBsZXZlbD0nW25vbmU6UmVnaW9uOm5rXScgbWVtYmVyPScmcXVvdDtDZW50cmFsJnF1b3Q7JyB1c2VyOnVpLWRvbWFpbj0nZGF0YWJhc2UnIHVzZXI6dWktZW51bWVyYXRpb249J2luY2x1c2l2ZScgdXNlcjp1aS1tYXJrZXI9J2VudW1lcmF0ZScgLz4KICAgIDwvZmlsdGVyPgogICAgPHRhYmxlIC8-CiAgPC93b3Jrc2hlZXQ-CiAgPHdvcmtzaGVldCBuYW1lPSdTYWxlcyBieSBTZWdtZW50Jz4KICAgIDxmaWx0ZXIgY2xhc3M9J2NhdGVnb3JpY2FsJyBjb2x1bW49J1tmZWRlcmF0ZWQuMTBubms4ZDF2Z213OHExN3l1NzZ1MDZwbmJjal0uW0FjdGlvbiAoU3RhdGUvUHJvdmluY2UpXSc-CiAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbWVtYmVyJyBsZXZlbD0nW1N0YXRlL1Byb3ZpbmNlXScgbWVtYmVyPScmcXVvdDtUZXhhcyZxdW90OycgdXNlcjp1aS1hY3Rpb24tZmlsdGVyPSdbQWN0aW9uMV0nIHVzZXI6dWktZG9tYWluPSdkYXRhYmFzZScgdXNlcjp1aS1lbnVtZXJhdGlvbj0naW5jbHVzaXZlJyB1c2VyOnVpLW1hcmtlcj0nZW51bWVyYXRlJyAvPgogICAgPC9maWx0ZXI-CiAgICA8ZmlsdGVyIGNsYXNzPSdjYXRlZ29yaWNhbCcgY29sdW1uPSdbZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLltub25lOlJlZ2lvbjpua10nIGZpbHRlci1ncm91cD0nMTQnPgogICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J21lbWJlcicgbGV2ZWw9J1tub25lOlJlZ2lvbjpua10nIG1lbWJlcj0nJnF1b3Q7Q2VudHJhbCZxdW90OycgdXNlcjp1aS1kb21haW49J2RhdGFiYXNlJyB1c2VyOnVpLWVudW1lcmF0aW9uPSdpbmNsdXNpdmUnIHVzZXI6dWktbWFya2VyPSdlbnVtZXJhdGUnIC8-CiAgICA8L2ZpbHRlcj4KICAgIDx0YWJsZSAvPgogIDwvd29ya3NoZWV0PgogIDx3b3Jrc2hlZXQgbmFtZT0nU2FsZXMgYnkgUHJvZHVjdCc-CiAgICA8ZmlsdGVyIGNsYXNzPSdjYXRlZ29yaWNhbCcgY29sdW1uPSdbZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLltBY3Rpb24gKFN0YXRlL1Byb3ZpbmNlKV0nPgogICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J21lbWJlcicgbGV2ZWw9J1tTdGF0ZS9Qcm92aW5jZV0nIG1lbWJlcj0nJnF1b3Q7VGV4YXMmcXVvdDsnIHVzZXI6dWktYWN0aW9uLWZpbHRlcj0nW0FjdGlvbjFdJyB1c2VyOnVpLWRvbWFpbj0nZGF0YWJhc2UnIHVzZXI6dWktZW51bWVyYXRpb249J2luY2x1c2l2ZScgdXNlcjp1aS1tYXJrZXI9J2VudW1lcmF0ZScgLz4KICAgIDwvZmlsdGVyPgogICAgPGZpbHRlciBjbGFzcz0nY2F0ZWdvcmljYWwnIGNvbHVtbj0nW2ZlZGVyYXRlZC4xMG5uazhkMXZnbXc4cTE3eXU3NnUwNnBuYmNqXS5bbm9uZTpSZWdpb246bmtdJyBmaWx0ZXItZ3JvdXA9JzE0Jz4KICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdtZW1iZXInIGxldmVsPSdbbm9uZTpSZWdpb246bmtdJyBtZW1iZXI9JyZxdW90O0NlbnRyYWwmcXVvdDsnIHVzZXI6dWktZG9tYWluPSdkYXRhYmFzZScgdXNlcjp1aS1lbnVtZXJhdGlvbj0naW5jbHVzaXZlJyB1c2VyOnVpLW1hcmtlcj0nZW51bWVyYXRlJyAvPgogICAgPC9maWx0ZXI-CiAgICA8dGFibGUgLz4KICA8L3dvcmtzaGVldD4KICA8d2luZG93cz4KICAgIDx3aW5kb3cgY2xhc3M9J3dvcmtzaGVldCcgbmFtZT0nU2FsZSBNYXAnPgogICAgICA8c2VsZWN0aW9uLWNvbGxlY3Rpb24-CiAgICAgICAgPHR1cGxlLXNlbGVjdGlvbj4KICAgICAgICAgIDx0dXBsZS1yZWZlcmVuY2U-CiAgICAgICAgICAgIDx0dXBsZS1kZXNjcmlwdG9yPgogICAgICAgICAgICAgIDxwYW5lLWRlc2NyaXB0b3I-CiAgICAgICAgICAgICAgICA8eC1maWVsZHM-CiAgICAgICAgICAgICAgICAgIDxmaWVsZD5bZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLltMb25naXR1ZGUgKGdlbmVyYXRlZCldPC9maWVsZD4KICAgICAgICAgICAgICAgIDwveC1maWVsZHM-CiAgICAgICAgICAgICAgICA8eS1maWVsZHM-CiAgICAgICAgICAgICAgICAgIDxmaWVsZD5bZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLltMYXRpdHVkZSAoZ2VuZXJhdGVkKV08L2ZpZWxkPgogICAgICAgICAgICAgICAgPC95LWZpZWxkcz4KICAgICAgICAgICAgICA8L3BhbmUtZGVzY3JpcHRvcj4KICAgICAgICAgICAgICA8Y29sdW1ucz4KICAgICAgICAgICAgICAgIDxmaWVsZD5bZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLltub25lOkNvdW50cnkvUmVnaW9uOm5rXTwvZmllbGQ-CiAgICAgICAgICAgICAgICA8ZmllbGQ-W2ZlZGVyYXRlZC4xMG5uazhkMXZnbXc4cTE3eXU3NnUwNnBuYmNqXS5bbm9uZTpTdGF0ZS9Qcm92aW5jZTpua108L2ZpZWxkPgogICAgICAgICAgICAgICAgPGZpZWxkPltmZWRlcmF0ZWQuMTBubms4ZDF2Z213OHExN3l1NzZ1MDZwbmJjal0uW0dlb21ldHJ5IChnZW5lcmF0ZWQpXTwvZmllbGQ-CiAgICAgICAgICAgICAgICA8ZmllbGQ-W2ZlZGVyYXRlZC4xMG5uazhkMXZnbXc4cTE3eXU3NnUwNnBuYmNqXS5bTGF0aXR1ZGUgKGdlbmVyYXRlZCldPC9maWVsZD4KICAgICAgICAgICAgICAgIDxmaWVsZD5bZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLltMb25naXR1ZGUgKGdlbmVyYXRlZCldPC9maWVsZD4KICAgICAgICAgICAgICAgIDxmaWVsZD5bZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLlt1c3I6Q2FsY3VsYXRpb25fOTkyMTEwMzE0NDEwMzc0Mzpxa108L2ZpZWxkPgogICAgICAgICAgICAgIDwvY29sdW1ucz4KICAgICAgICAgICAgPC90dXBsZS1kZXNjcmlwdG9yPgogICAgICAgICAgICA8dHVwbGU-CiAgICAgICAgICAgICAgPHZhbHVlPiZxdW90O1VuaXRlZCBTdGF0ZXMmcXVvdDs8L3ZhbHVlPgogICAgICAgICAgICAgIDx2YWx1ZT4mcXVvdDtUZXhhcyZxdW90OzwvdmFsdWU-CiAgICAgICAgICAgICAgPHZhbHVlPiZxdW90O01VTFRJUE9MWUdPTigoKC05Ny4xNDYzIDI1Ljk1NTYsLTk3LjIwOCAyNS45NjM2LC05Ny4yNzcyIDI1LjkzNTQsLTk3LjM0ODkgMjUuOTMwOCwtOTcuMzc0NCAyNS45MDc0LC05Ny4zNTc2IDI1Ljg4NjksLTk3LjM3MzcgMjUuODQsLTk3LjQ1MzkgMjUuODU0NCwtOTcuNDU2NCAyNS44ODM4LC05Ny41MjE4IDI1Ljg4NjUsLTk3LjU0ODIgMjUuOTM1NSwtOTcuNTgyNiAyNS45Mzc5LC05Ny42NDQ5IDI2LjAyNzUsLTk3LjcwNjcgMjYuMDM3NCwtOTcuNzY0MSAyNi4wMjg2LC05Ny44MDEzIDI2LjA2LC05Ny44MzU1IDI2LjA0NjksLTk3Ljg2MTkgMjYuMDY5OCwtOTcuOTA5OSAyNi4wNTY5LC05Ny45NjYxIDI2LjA1MTksLTk4LjAzMDggMjYuMDY1LC05OC4wNzAxIDI2LjAzNzksLTk4LjA3OTEgMjYuMDcwNSwtOTguMTM1NSAyNi4wNzIsLTk4LjE1NzUgMjYuMDU0NCwtOTguMTk3IDI2LjA1NjIsLTk4LjMwNjUgMjYuMTA0MywtOTguMzM1MiAyNi4xMzc2LC05OC4zODY3IDI2LjE1NzksLTk4LjQ0NDMgMjYuMjAxMiwtOTguNDQ1MiAyNi4yMjQ2LC05OC41MDYxIDI2LjIwOSwtOTguNTIyNCAyNi4yMjA5LC05OC41NjE1IDI2LjIyNDUsLTk4LjU4NjcgMjYuMjU3NSwtOTguNjU0MiAyNi4yMzYsLTk4LjY3OTQgMjYuMjQ5MiwtOTguNzUzOCAyNi4zMzE3LC05OC43ODk4IDI2LjMzMTYsLTk4LjgyNjkgMjYuMzY5NiwtOTguODk2MiAyNi4zNTMyLC05OC45MjkyIDI2LjM5MzIsLTk4Ljk0NjUgMjYuMzY5OSwtOTguOTc0MiAyNi40MDExLC05OS4wMTA2IDI2LjM5MjEsLTk5LjA0IDI2LjQxMjksLTk5LjA5NDggMjYuNDEwOSwtOTkuMTEwOSAyNi40MjYzLC05OS4wOTE2IDI2LjQ3NjQsLTk5LjEyODQgMjYuNTI1NSwtOTkuMTY2NyAyNi41MzYxLC05OS4xNjk0IDI2LjU3MTcsLTk5LjIwMDIgMjYuNjU1OCwtOTkuMjA4OSAyNi43MjQ4LC05OS4yNCAyNi43NDU5LC05OS4yNDI0IDI2Ljc4ODMsLTk5LjI2ODYgMjYuODQzMiwtOTkuMzI4OSAyNi44ODAyLC05OS4zMjE4IDI2LjkwNjgsLTk5LjM4ODMgMjYuOTQ0MiwtOTkuMzc3MyAyNi45NzM4LC05OS40MTU1IDI3LjAxNzIsLTk5LjQ0NjUgMjcuMDIzLC05OS40NTEgMjcuMDY2OCwtOTkuNDMwMyAyNy4wOTQ5LC05OS40Mzk2IDI3LjE1MjEsLTk5LjQyNjQgMjcuMTc4MywtOTkuNDUzOCAyNy4yNjUxLC05OS40OTY2IDI3LjI3MTcsLTk5LjQ5NSAyNy4zMDM5LC05OS41Mzc5IDI3LjMxNzUsLTk5LjUwNDQgMjcuMzM5OSwtOTkuNDgwNCAyNy40ODE2LC05OS41MjgzIDI3LjQ5ODksLTk5LjUxMTEgMjcuNTY0NSwtOTkuNTU2OCAyNy42MTQzLC05OS41OCAyNy42MDIzLC05OS41OTQgMjcuNjM4NiwtOTkuNjM4OSAyNy42MjY4LC05OS42OTEzIDI3LjY2ODcsLTk5LjcyODQgMjcuNjc5MywtOTkuNzcwNyAyNy43MzIxLC05OS44MzMxIDI3Ljc2MjksLTk5Ljg3MjMgMjcuNzk1MywtOTkuODgxMyAyNy44NDk2LC05OS45MDE1IDI3Ljg2NDIsLTk5LjkwMDEgMjcuOTEyMSwtOTkuOTM3MSAyNy45NDA1LC05OS45MzE4IDI3Ljk4MSwtOTkuOTg5OCAyNy45OTI5LC0xMDAuMDE5IDI4LjA2NjQsLTEwMC4wNTYxIDI4LjA5MTMsLTEwMC4wODY5IDI4LjE0NjgsLTEwMC4xNTkyIDI4LjE2NzYsLTEwMC4yMTIyIDI4LjE5NjgsLTEwMC4yMjM2IDI4LjIzNTIsLTEwMC4yNTc4IDI4LjI0MDMsLTEwMC4yOTM1IDI4LjI3ODUsLTEwMC4yODg2IDI4LjMxNywtMTAwLjM0OTMgMjguNDAxNCwtMTAwLjMzNjIgMjguNDMwMiwtMTAwLjM2ODIgMjguNDc4OSwtMTAwLjMzNDcgMjguNTAwMywtMTAwLjM4NyAyOC41MTQsLTEwMC40MTA0IDI4LjU1NDMsLTEwMC4zOTg1IDI4LjU4NTIsLTEwMC40NDc2IDI4LjYxMDEsLTEwMC40NDU3IDI4LjY0MDYsLTEwMC41MDA0IDI4LjY2MiwtMTAwLjUwNzYgMjguNzQwNiwtMTAwLjUzMzYgMjguNzYxMSwtMTAwLjU0NjYgMjguODI0OSwtMTAwLjU3MDUgMjguODI2MywtMTAwLjU5MTUgMjguODg5MywtMTAwLjY0ODggMjguOTQxLC0xMDAuNjQ1OSAyOC45ODY0LC0xMDAuNjY3NSAyOS4wODQzLC0xMDAuNzc1OSAyOS4xNzMzLC0xMDAuNzY1OSAyOS4xODc1LC0xMDAuNzk0OCAyOS4yNDE2LC0xMDAuODc2MSAyOS4yNzk2LC0xMDAuODg2OCAyOS4zMDc4LC0xMDAuOTUwNyAyOS4zNDc3LC0xMDEuMDA2NiAyOS4zNjYsLTEwMS4wNjAyIDI5LjQ1ODcsLTEwMS4xNTE5IDI5LjQ3NywtMTAxLjE3MzggMjkuNTE0NiwtMTAxLjI2MTIgMjkuNTM2OCwtMTAxLjI0MSAyOS41NjUsLTEwMS4yNjIyIDI5LjYzMDYsLTEwMS4yOTEgMjkuNTcxNSwtMTAxLjMxMTYgMjkuNTg1MSwtMTAxLjMgMjkuNjQwNywtMTAxLjMxNDEgMjkuNjU5MSwtMTAxLjM2MzIgMjkuNjUyNiwtMTAxLjM3NTQgMjkuNzAxOCwtMTAxLjQxNTYgMjkuNzQ2NSwtMTAxLjQ0ODkgMjkuNzUwNywtMTAxLjQ1NTggMjkuNzg4LC0xMDEuNTM5MiAyOS43NjE4LC0xMDEuNTQxOSAyOS44MTA4LC0xMDEuNTc1OCAyOS43NjkzLC0xMDEuNzEwNiAyOS43NjE3LC0xMDEuNzYwOSAyOS43ODIxLC0xMDEuODA2MiAyOS43ODA4LC0xMDEuODUzNCAyOS44MDc5LC0xMDEuOTMzNSAyOS43ODUxLC0xMDIuMDM4MyAyOS44MDMxLC0xMDIuMDQ5IDI5Ljc4NTYsLTEwMi4xMTYxIDI5Ljc5MjUsLTEwMi4xOTQ5IDI5LjgzNzEsLTEwMi4zMjA3IDI5Ljg3ODksLTEwMi4zNjQ4IDI5Ljg0NDMsLTEwMi4zODk3IDI5Ljc4MTksLTEwMi41MTc0IDI5Ljc4MzgsLTEwMi41NDggMjkuNzQ1LC0xMDIuNTcyNCAyOS43NTYxLC0xMDIuNjIzIDI5LjczNjQsLTEwMi42NzQ5IDI5Ljc0NDMsLTEwMi42OTM0IDI5LjY3NzIsLTEwMi43NDIyIDI5LjYzMDcsLTEwMi43NDUgMjkuNTkzMiwtMTAyLjc2ODMgMjkuNTk0NywtMTAyLjc3MTQgMjkuNTQ4OSwtMTAyLjgwODQgMjkuNTIyOSwtMTAyLjgzMSAyOS40NDQzLC0xMDIuODI0NyAyOS4zOTczLC0xMDIuODM5OSAyOS4zNjA2LC0xMDIuODc4NiAyOS4zNTM5LC0xMDIuOTAzMiAyOS4yNTQsLTEwMi44NzA2IDI5LjIzNjksLTEwMi44OTAxIDI5LjIwODgsLTEwMi45NTAyIDI5LjE3MzYsLTEwMi45NzM4IDI5LjE4NTUsLTEwMy4wMzI1IDI5LjEwNDcsLTEwMy4wNzUzIDI5LjA5MjMsLTEwMy4xMDA3IDI5LjA2MDIsLTEwMy4xMTUzIDI4Ljk4NTMsLTEwMy4xNTMzIDI4Ljk3MTgsLTEwMy4yMjc0IDI4Ljk5MTUsLTEwMy4yNzkyIDI4Ljk3NzcsLTEwMy4yOTg2IDI5LjAwNjgsLTEwMy40MzM3IDI5LjA0NSwtMTAzLjQ1MDYgMjkuMDcyOCwtMTAzLjU1NDUgMjkuMTU4NSwtMTAzLjcxOTIgMjkuMTgxNCwtMTAzLjc5MjcgMjkuMjYyMywtMTAzLjgxNDcgMjkuMjczOCwtMTAzLjk2OTYgMjkuMjk3OCwtMTA0LjAxOTkgMjkuMzEyMSwtMTA0LjEwNjUgMjkuMzczMSwtMTA0LjE2MyAyOS4zOTE5LC0xMDQuMjE3NSAyOS40NTU5LC0xMDQuMjA5IDI5LjQ4MSwtMTA0LjI2NDIgMjkuNTE0LC0xMDQuMzM4MSAyOS41MiwtMTA0LjQwMDYgMjkuNTczLC0xMDQuNDY2OSAyOS42MDk2LC0xMDQuNTQ0MiAyOS42ODE2LC0xMDQuNTY2MSAyOS43NzE0LC0xMDQuNjI5NSAyOS44NTIzLC0xMDQuNjgyNSAyOS45MzQ4LC0xMDQuNjc0IDI5Ljk1NjcsLTEwNC43MDYzIDMwLjA0OTcsLTEwNC42ODc5IDMwLjA3MzksLTEwNC42OTY2IDMwLjEzNDQsLTEwNC42ODcyIDMwLjE3OSwtMTA0LjcwNjggMzAuMjM1NCwtMTA0Ljc2MzIgMzAuMjc0NCwtMTA0Ljc3MzUgMzAuMzAyNywtMTA0LjgyMjYgMzAuMzUwMywtMTA0LjgxNjMgMzAuMzc0MywtMTA0Ljg1OTUgMzAuMzkxMSwtMTA0Ljg2OTQgMzAuNDc3MywtMTA0Ljg4MjQgMzAuNTMyMywtMTA0LjkxOSAzMC41OTc3LC0xMDQuOTcyMSAzMC42MTAzLC0xMDUuMDA2NSAzMC42ODU4LC0xMDUuMDYyNSAzMC42ODY2LC0xMDUuMTE4MSAzMC43NDk1LC0xMDUuMTYxNyAzMC43NTIxLC0xMDUuMjE3NyAzMC44MDYsLTEwNS4yNTYxIDMwLjc5NDUsLTEwNS4yOTE3IDMwLjgyNjEsLTEwNS4zNjE1IDMwLjg1MDMsLTEwNS4zOTU2IDMwLjg0OSwtMTA1LjQxMzUgMzAuODk5OCwtMTA1LjQ5ODggMzAuOTUwMywtMTA1LjU3ODYgMzEuMDIwNiwtMTA1LjU4NTEgMzEuMDU2OSwtMTA1LjY0NjcgMzEuMTEzOSwtMTA1Ljc3MzkgMzEuMTY4LC0xMDUuODE4OCAzMS4yMzA3LC0xMDUuODc0NyAzMS4yOTEzLC0xMDUuOTMxMiAzMS4zMTI3LC0xMDUuOTUzOSAzMS4zNjQ3LC0xMDYuMDE2MiAzMS4zOTM1LC0xMDYuMDc1MyAzMS4zOTc2LC0xMDYuMTkxMSAzMS40NTk5LC0xMDYuMjE5NiAzMS40ODE2LC0xMDYuMjQ1MiAzMS41MzkxLC0xMDYuMjgwMSAzMS41NjE1LC0xMDYuMzA3OSAzMS42Mjk1LC0xMDYuMzgxMSAzMS43MzIxLC0xMDYuNDUxNCAzMS43NjQ0LC0xMDYuNDkwNSAzMS43NDg5LC0xMDYuNTI4MiAzMS43ODMxLC0xMDYuNTQ3MSAzMS44MDczLC0xMDYuNjA1MyAzMS44Mjc3LC0xMDYuNjQ1NSAzMS44OTg3LC0xMDYuNjExOCAzMS45MiwtMTA2LjYxODUgMzIuMDAwNSwtMTA1Ljk5OCAzMi4wMDIzLC0xMDUuMjUwNSAzMi4wMDAzLC0xMDQuODQ3OCAzMi4wMDA1LC0xMDQuMDI0NSAzMiwtMTAzLjA2NDQgMzIuMDAwNSwtMTAzLjA2NDcgMzIuOTU5MSwtMTAzLjA1NjcgMzMuMzg4NCwtMTAzLjA0NCAzMy45NzQ2LC0xMDMuMDQyNCAzNS4xODMxLC0xMDMuMDQwOCAzNi4wNTUyLC0xMDMuMDQxOSAzNi41MDA0LC0xMDMuMDAyNCAzNi41MDA0LC0xMDIuMDMyMyAzNi41MDA2LC0xMDEuNjIzOSAzNi40OTk1LC0xMDEuMDg1MiAzNi40OTkyLC0xMDAuMDAwNCAzNi40OTk3LC0xMDAuMDAwNCAzNC43NDY1LC05OS45OTc1IDM0LjU2MDYsLTk5LjkyMzIgMzQuNTc0NiwtOTkuODQ0NiAzNC41MDY5LC05OS43NTM0IDM0LjQyMDksLTk5LjY5NDUgMzQuMzc4MiwtOTkuNiAzNC4zNzQ3LC05OS41Nzk4IDM0LjQxNjksLTk5LjUxNzYgMzQuNDE0NSwtOTkuNDMzNSAzNC4zNzAyLC05OS4zOTg3IDM0LjM3NTgsLTk5LjM5NTIgMzQuNDQyLC05OS4zNzU2IDM0LjQ1ODgsLTk5LjMyMDEgMzQuNDA5MywtOTkuMjYxMyAzNC40MDM1LC05OS4yMTA4IDM0LjMzNjgsLTk5LjE4OTggMzQuMjE0NCwtOTkuMDk1MyAzNC4yMTE4LC05OS4wNDM0IDM0LjE5ODIsLTk4Ljk5MTcgMzQuMjIxNCwtOTguOTUyNCAzNC4yMTI1LC05OC44NjAxIDM0LjE0OTksLTk4LjgzMTEgMzQuMTYyMiwtOTguNzY2NyAzNC4xMzY4LC05OC42OTAxIDM0LjEzMzIsLTk4LjY0ODEgMzQuMTY0NCwtOTguNjEwMiAzNC4xNTcxLC05OC41NjAyIDM0LjEzMzIsLTk4LjQ4NyAzNC4wNjI5LC05OC40MjM1IDM0LjA4MjgsLTk4LjM5ODQgMzQuMTI4NSwtOTguMzY0IDM0LjE1NzEsLTk4LjMwMDIgMzQuMTM0NiwtOTguMjMyNSAzNC4xMzQ2LC05OC4xNjg4IDM0LjExNDMsLTk4LjEzOTEgMzQuMTQxOSwtOTguMTAxOSAzNC4xNDY4LC05OC4wOTA1IDM0LjEyMjUsLTk4LjEyMDIgMzQuMDcyMSwtOTguMDgzOCAzNC4wNDE3LC05OC4wODQ0IDM0LjAwMjksLTk4LjAxNjMgMzMuOTk0MSwtOTcuOTc0MiAzNC4wMDY3LC05Ny45NDY4IDMzLjk5MDksLTk3Ljk3MTIgMzMuOTM3MiwtOTcuOTU3MiAzMy45MTQ1LC05Ny45Nzc5IDMzLjg4OTksLTk3Ljg3MTQgMzMuODQ5LC05Ny44MzQzIDMzLjg1NzcsLTk3Ljc2MyAzMy45MzQxLC05Ny43MzIzIDMzLjkzNjcsLTk3LjY4NzcgMzMuOTg3MiwtOTcuNjYxNSAzMy45OTA4LC05Ny41ODg4IDMzLjk1MTksLTk3LjU4OTMgMzMuOTAzOSwtOTcuNTYwOSAzMy44OTk2LC05Ny40ODQyIDMzLjkxNTQsLTk3LjQ1MTEgMzMuODkxNywtOTcuNDYyOSAzMy44NDI5LC05Ny40NDM5IDMzLjgyMzcsLTk3LjM3MjkgMzMuODE5NSwtOTcuMzMxOSAzMy44ODQ1LC05Ny4yNTU2IDMzLjg2MzcsLTk3LjI0NjIgMzMuOTAwMywtOTcuMjEwMyAzMy45MTU5LC05Ny4xODU1IDMzLjkwMDcsLTk3LjE2NjggMzMuODQwNCwtOTcuMTk3NCAzMy44Mjk4LC05Ny4xOTM0IDMzLjc2MDYsLTk3LjE1MTMgMzMuNzIyNiwtOTcuMTExMSAzMy43MTk0LC05Ny4wODg3IDMzLjczODcsLTk3LjA4OCAzMy44MDg3LC05Ny4wNDggMzMuODE3OSwtOTcuMDg3MyAzMy44Mzk4LC05Ny4wNTczIDMzLjg1NjksLTk3LjAyMzUgMzMuODQ0NSwtOTYuOTg1NiAzMy44ODY1LC05Ni45OTYzIDMzLjk0MjcsLTk2LjkzNDggMzMuOTU0NSwtOTYuODk5NCAzMy45MzM3LC05Ni44ODMgMzMuODY4LC05Ni44NTA2IDMzLjg0NzIsLTk2LjgzMjIgMzMuODc0OCwtOTYuNzc5NiAzMy44NTc5LC05Ni43Njk0IDMzLjgyNzUsLTk2LjcxMzcgMzMuODMxMywtOTYuNjkwNyAzMy44NSwtOTYuNjczNCAzMy45MTIzLC05Ni41ODg1IDMzLjg5NSwtOTYuNjI5IDMzLjg1MjQsLTk2LjU3MzIgMzMuODE5MiwtOTYuNTMyOSAzMy44MjMsLTk2LjUwMDcgMzMuNzcyNiwtOTYuNDIyNiAzMy43NzYsLTk2LjM3OTUgMzMuNzI1OCwtOTYuMzYyMiAzMy42OTE4LC05Ni4zMTg0IDMzLjY5NzEsLTk2LjMwMyAzMy43NTA5LC05Ni4yNzczIDMzLjc2OTcsLTk2LjIzMDQgMzMuNzQ4NSwtOTYuMTc4MSAzMy43NjA1LC05Ni4xNDkyIDMzLjgzNzEsLTk2LjEwMTUgMzMuODQ2NywtOTYuMDQ4OCAzMy44MzY1LC05NS45NDE5IDMzLjg2MSwtOTUuOTMyMSAzMy44ODY1LC05NS44NDMzIDMzLjgzODMsLTk1LjgwNDUgMzMuODYyMiwtOTUuNzY3OSAzMy44NDY4LC05NS43NTY2IDMzLjg5MiwtOTUuNjk0OSAzMy44ODY4LC05NS42Njg2IDMzLjkwNywtOTUuNjI3MyAzMy45MDc4LC05NS41OTc1IDMzLjk0MjMsLTk1LjU1NzcgMzMuOTMwNCwtOTUuNTQzNCAzMy44ODA1LC05NS40NTk4IDMzLjg4OCwtOTUuNDM4MiAzMy44NjcxLC05NS4zMTA1IDMzLjg3NzIsLTk1LjI4MjIgMzMuODc1OSwtOTUuMjcxNCAzMy45MTI2LC05NS4yMTk0IDMzLjk2MTYsLTk1LjE1NTkgMzMuOTM2OCwtOTUuMTI5NiAzMy45MzY3LC05NS4xMTc2IDMzLjkwNDYsLTk1LjA4MjQgMzMuODc5OSwtOTUuMDYwMSAzMy45MDE5LC05NS4wNDkgMzMuODY0MSwtOTQuOTY4OSAzMy44NjA5LC05NC45NTM1IDMzLjgxNjUsLTk0LjkyMzMgMzMuODA4NywtOTQuOTExNSAzMy43Nzg0LC05NC44NDkzIDMzLjczOTYsLTk0LjgyMzQgMzMuNzY5MiwtOTQuODAyMyAzMy43MzI4LC05NC43NzEzIDMzLjc2MDcsLTk0Ljc0NjEgMzMuNzAzLC05NC42ODQ4IDMzLjY4NDQsLTk0LjY2NzkgMzMuNjk0NiwtOTQuNjM5MiAzMy42NjM3LC05NC42MjE0IDMzLjY4MjYsLTk0LjU5MDggMzMuNjQ1NiwtOTQuNTQ2NCAzMy42NiwtOTQuNTIwNCAzMy42MTc1LC05NC40ODU5IDMzLjYzNzksLTk0LjM4OTUgMzMuNTQ2NywtOTQuMzUzNiAzMy41NDQsLTk0LjM0NTUgMzMuNTY3MywtOTQuMzA5NiAzMy41NTE3LC05NC4yNzU5IDMzLjU1OCwtOTQuMjE5MiAzMy41NTYxLC05NC4xODQzIDMzLjU5NDYsLTk0LjE0NzQgMzMuNTY1MiwtOTQuMDgyNCAzMy41NzU3LC05NC4wNDM0IDMzLjU1MjMsLTk0LjA0MyAzMy4wMTkyLC05NC4wNDI3IDMxLjk5OTMsLTk0LjAxNTYgMzEuOTc5OSwtOTMuOTcwOCAzMS45MiwtOTMuOTI5OSAzMS45MTI3LC05My44OTY3IDMxLjg4NTMsLTkzLjg3NDggMzEuODIyMywtOTMuODIyNiAzMS43NzM2LC05My44MzY5IDMxLjc1MDIsLTkzLjc5NDUgMzEuNzAyMSwtOTMuODIxNyAzMS42NzQsLTkzLjgxODcgMzEuNjE0NiwtOTMuODM0OSAzMS41ODYyLC05My43ODUgMzEuNTI2LC05My43MTI1IDMxLjUxMzQsLTkzLjc0OTUgMzEuNDY4NywtOTMuNjkyNiAzMS40MzcyLC05My43MDQ5IDMxLjQxMDksLTkzLjY3NDEgMzEuMzk3NywtOTMuNjY5MSAzMS4zNjU0LC05My42ODc1IDMxLjMxMDgsLTkzLjU5ODQgMzEuMjMxMSwtOTMuNjAwMyAzMS4xNzYyLC05My41NTI2IDMxLjE4NTYsLTkzLjUzOTQgMzEuMTE1MiwtOTMuNTYzMiAzMS4wOTcsLTkzLjUyNzYgMzEuMDc0NSwtOTMuNTA4OSAzMS4wMjkzLC05My41NTYzIDMxLjAwNDEsLTkzLjU2ODQgMzAuOTY5MSwtOTMuNTMyMSAzMC45NTc5LC05My41MjYzIDMwLjkyOTcsLTkzLjU1ODYgMzAuOTEzMiwtOTMuNTUzNiAzMC44MzUxLC05My42MTQ4IDMwLjc1NiwtOTMuNjA3NyAzMC43MTU2LC05My42MzE1IDMwLjY3OCwtOTMuNjgzMSAzMC42NDA4LC05My42Nzg4IDMwLjU5ODYsLTkzLjcyNzUgMzAuNTc0NywtOTMuNzMzOCAzMC41MzE3LC05My42OTc4IDMwLjQ0MzgsLTkzLjc0MTcgMzAuNDAyMywtOTMuNzYyMyAzMC4zNTM3LC05My43NDIxIDMwLjMwMSwtOTMuNzA0NyAzMC4yODk5LC05My43MDcgMzAuMjQzNywtOTMuNzIxIDMwLjIxMDQsLTkzLjY5MjggMzAuMTM1MiwtOTMuNzMyOCAzMC4wODI5LC05My43MjI1IDMwLjA1MDksLTkzLjc1NTEgMzAuMDE1MywtOTMuODcxNyAyOS45ODEsLTkzLjg2OTIgMjkuOTM4LC05My45NTA2IDI5Ljg0OTMsLTkzLjk0NjYgMjkuNzgwMSwtOTMuODM3NyAyOS42NzksLTk0LjAxNDMgMjkuNjc5OCwtOTQuMzU0MyAyOS41NjEsLTk0LjQ5OTEgMjkuNTA2OCwtOTQuNDcwMiAyOS41NTcxLC05NC41NDU5IDI5LjU3MjUsLTk0Ljc2MjUgMjkuNTI0MSwtOTQuNzAzOSAyOS42MzI1LC05NC42OTU3IDI5Ljc1NjUsLTk0LjczODkgMjkuNzkwNiwtOTQuODE0MSAyOS43NTksLTk0Ljg3MjggMjkuNjcxNCwtOTQuOTMwMyAyOS42NzM3LC05NS4wMTY2IDI5LjcyMDUsLTk1LjA3MjYgMjkuODI2MiwtOTUuMDk1NSAyOS43NTc2LC05NC45ODMzIDI5LjY4MjMsLTk0Ljk5ODUgMjkuNjE2NCwtOTUuMDc4OSAyOS41MzUzLC05NS4wMTcgMjkuNTQ4LC05NC45MDk2IDI5LjQ5NjEsLTk0Ljk1MDQgMjkuNDY2NywtOTQuODg1NCAyOS4zODk3LC05NS4wNTc0IDI5LjIwMTMsLTk1LjE0OTYgMjkuMTgwNSwtOTUuMjM0MiAyOC45OTI2LC05NS4zODU2IDI4Ljg2NDYsLTk1LjUwNzIgMjguODI1NCwtOTUuNjUzNyAyOC43NDk5LC05NS42NzI3IDI4Ljc0OTUsLTk1Ljc4NCAyOC42Nzk0LC05NS45MTQ5IDI4LjYzODgsLTk1LjY3NzYgMjguNzQ5NCwtOTUuNzg1MyAyOC43NDcxLC05NS45MjM2IDI4LjcwMTUsLTk1Ljk2MDggMjguNjE1MiwtOTYuMzM1NSAyOC40MzgxLC05Ni4xNDYzIDI4LjU0MjcsLTk1Ljk5MDYgMjguNjAxNiwtOTYuMDM4OCAyOC42NTI4LC05Ni4xNTI0IDI4LjYxMzUsLTk2LjIzNTQgMjguNjQyNywtOTYuMjA3OCAyOC42OTgxLC05Ni4zMjI5IDI4LjY0MTksLTk2LjM4NiAyOC42NzQ4LC05Ni40Mjg0IDI4LjcwNzEsLTk2LjQzNDggMjguNjAzLC05Ni41NjE1IDI4LjY0NTQsLTk2LjU3MzYgMjguNzA1NSwtOTYuNjU5NiAyOC43MjI2LC05Ni42NjE0IDI4LjcwMjYsLTk2LjYxMjEgMjguNjM5NCwtOTYuNjM4NSAyOC41NzE5LC05Ni41NjY3IDI4LjU4MjUsLTk2LjQxNTMgMjguNDYzNywtOTYuNDMyMiAyOC40MzI1LC05Ni42NTAzIDI4LjMzMjUsLTk2LjcwODQgMjguNDA3NSwtOTYuNzg1NyAyOC40NDc2LC05Ni43ODMyIDI4LjQwMDQsLTk2Ljg1ODkgMjguNDE3NiwtOTYuNzkwNSAyOC4zMTkyLC05Ni44MDk1IDI4LjIxOTksLTk2LjkxMTEgMjguMTM1NywtOTYuOTg2OCAyOC4xMjg3LC05Ny4wMzczIDI4LjIwMTMsLTk3LjI0MTUgMjguMDYyMywtOTcuMTUgMjguMDMzOCwtOTcuMTM1NCAyOC4wNDcyLC05Ny4wMjQ2IDI4LjExMzMsLTk3LjAzMSAyOC4wNDg2LC05Ny4xMzM4IDI3LjkwMDksLTk3LjE1NjkgMjcuODcyOCwtOTcuMjEzNCAyNy44MjEsLTk3LjI1MDEgMjcuODc2NCwtOTcuMzU0OCAyNy44NTAyLC05Ny4zMzEyIDI3Ljg3MzgsLTk3LjUyODEgMjcuODQ3NCwtOTcuMzgyOSAyNy44Mzg3LC05Ny4zNjE3IDI3LjczNTEsLTk3LjI0NSAyNy42OTMxLC05Ny4zMjQ4IDI3LjU2MSwtOTcuNDEyMyAyNy4zMjI0LC05Ny41MDExIDI3LjI5MTUsLTk3LjQ3MzcgMjcuNDAyOSwtOTcuNTMzOSAyNy4zMzk4LC05Ny42Mzc0IDI3LjMwMSwtOTcuNzM1MiAyNy40MTgyLC05Ny42NjE5IDI3LjI4NzUsLTk3Ljc5NjYgMjcuMjcyNiwtOTcuNjU3NCAyNy4yNzM3LC05Ny41MzQxIDI3LjIyNTMsLTk3LjQ0ODcgMjcuMjYzMSwtOTcuNDUxMSAyNy4xMjE2LC05Ny41MDUyIDI3LjA4NTYsLTk3LjQ3OSAyNi45OTkxLC05Ny41NjE0IDI2Ljk5OCwtOTcuNTYyOSAyNi44Mzg5LC05Ny40NzEgMjYuNzUwMSwtOTcuNDQ2NCAyNi41OTk5LC05Ny40MTc3IDI2LjM3MDIsLTk3LjM0MDYgMjYuMzMxOCwtOTcuMjk1NSAyNi4xOTA4LC05Ny4zMTIxIDI2LjEyMTYsLTk3LjIzNjUgMjYuMDY0NiwtOTcuMjUxNiAyNS45NjQzLC05Ny4xNTI3IDI2LjAyNzUsLTk3LjE0NjMgMjUuOTU1NikpLCgoLTk0LjUxMTcgMjkuNTE1OCwtOTQuNjU5MiAyOS40Mzc1LC05NC43MjgyIDI5LjM3MTYsLTk0Ljc3NzQgMjkuMzc1OSwtOTQuNjg1MiAyOS40NTEzLC05NC41MTE3IDI5LjUxNTgpKSwoKC05NC43NTE4IDI5LjMzMjksLTk0LjgwNDkgMjkuMjc4NywtOTUuMDU2MiAyOS4xMjk5LC05NC44NjEzIDI5LjI5NTMsLTk0Ljc1MTggMjkuMzMyOSkpLCgoLTk2LjgyMDEgMjguMTY0NSwtOTYuNzAzNyAyOC4xOTgsLTk2LjM4NzUgMjguMzc2MiwtOTYuNDQwMyAyOC4zMTg4LC05Ni42ODc4IDI4LjE4NTksLTk2Ljg0NzkgMjguMDY1MSwtOTYuODIwMSAyOC4xNjQ1KSksKCgtOTYuODcyMiAyOC4xMzE1LC05Ni44NSAyOC4wNjM4LC05Ny4wNTU0IDI3Ljg0NzIsLTk2Ljk2MzIgMjguMDIyOSwtOTYuODcyMiAyOC4xMzE1KSksKCgtOTcuMjk0MyAyNi42MDAzLC05Ny4zMjU0IDI2LjYwMDMsLTk3LjMwOTQgMjYuNjI5OCwtOTcuMzkyMSAyNi45MzY3LC05Ny4zOTE2IDI3LjEyNTgsLTk3LjM2NjEgMjcuMjc4MSwtOTcuMzcxMiAyNy4yNzgxLC05Ny4zMzAyIDI3LjQzNTIsLTk3LjI0NzIgMjcuNTgxNSwtOTcuMTk2NCAyNy42ODM3LC05Ny4wOTI1IDI3LjgxMTQsLTk3LjA0NDYgMjcuODM0NCwtOTcuMTUwNCAyNy43MDI3LC05Ny4yMjI3IDI3LjU3NjUsLTk3LjM0NzIgMjcuMjc4LC05Ny4zNzkzIDI3LjA0MDIsLTk3LjM3MDUgMjYuOTA4MSwtOTcuMjkwMSAyNi42MDAzLC05Ny4yOTQzIDI2LjYwMDMpKSkmcXVvdDs8L3ZhbHVlPgogICAgICAgICAgICAgIDx2YWx1ZT4zMS4yNTwvdmFsdWU-CiAgICAgICAgICAgICAgPHZhbHVlPi05OS4yNTwvdmFsdWU-CiAgICAgICAgICAgICAgPHZhbHVlPi0wLjE1MTE4MTkyNDU1MzI0NTk0PC92YWx1ZT4KICAgICAgICAgICAgPC90dXBsZT4KICAgICAgICAgIDwvdHVwbGUtcmVmZXJlbmNlPgogICAgICAgIDwvdHVwbGUtc2VsZWN0aW9uPgogICAgICA8L3NlbGVjdGlvbi1jb2xsZWN0aW9uPgogICAgPC93aW5kb3c-CiAgPC93aW5kb3dzPgo8L2N1c3RvbWl6ZWQtdmlldz4K"
+ },
+ {
+ "isSourceView": false,
+ "viewName": "Product",
+ "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IGRhc2hib2FyZD0nUHJvZHVjdCcgc291cmNlLWJ1aWxkPScyMDI0LjIuMCAoMjAyNDIuMjQuMDcxNi4xOTQ0KScgdmVyc2lvbj0nMTguMScgeG1sbnM6dXNlcj0naHR0cDovL3d3dy50YWJsZWF1c29mdHdhcmUuY29tL3htbC91c2VyJz4KICA8YWN0aXZlIGlkPSctMScgLz4KICA8ZGF0YXNvdXJjZXM-CiAgICA8ZGF0YXNvdXJjZSBuYW1lPSdmZWRlcmF0ZWQuMTBubms4ZDF2Z213OHExN3l1NzZ1MDZwbmJjaic-CiAgICAgIDxncm91cCBjYXB0aW9uPSdBY3Rpb24gKENhdGVnb3J5LFlFQVIoT3JkZXIgRGF0ZSksTU9OVEgoT3JkZXIgRGF0ZSkpJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKENhdGVnb3J5LFlFQVIoT3JkZXIgRGF0ZSksTU9OVEgoT3JkZXIgRGF0ZSkpXScgbmFtZS1zdHlsZT0ndW5xdWFsaWZpZWQnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnPgogICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nY3Jvc3Nqb2luJz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1tDYXRlZ29yeV0nIC8-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbeXI6T3JkZXIgRGF0ZTpva10nIC8-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbbW46T3JkZXIgRGF0ZTpva10nIC8-CiAgICAgICAgPC9ncm91cGZpbHRlcj4KICAgICAgPC9ncm91cD4KICAgICAgPGNvbHVtbiBjYXB0aW9uPSdBY3Rpb24gKENhdGVnb3J5LFlFQVIoT3JkZXIgRGF0ZSksTU9OVEgoT3JkZXIgRGF0ZSkpJyBkYXRhdHlwZT0ndHVwbGUnIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoQ2F0ZWdvcnksWUVBUihPcmRlciBEYXRlKSxNT05USChPcmRlciBEYXRlKSldJyByb2xlPSdkaW1lbnNpb24nIHR5cGU9J25vbWluYWwnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnIC8-CiAgICAgIDxncm91cCBjYXB0aW9uPSdBY3Rpb24gKFlFQVIoT3JkZXIgRGF0ZSksTU9OVEgoT3JkZXIgRGF0ZSkpJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKFlFQVIoT3JkZXIgRGF0ZSksTU9OVEgoT3JkZXIgRGF0ZSkpXScgbmFtZS1zdHlsZT0ndW5xdWFsaWZpZWQnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnPgogICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nY3Jvc3Nqb2luJz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1t5cjpPcmRlciBEYXRlOm9rXScgLz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1ttbjpPcmRlciBEYXRlOm9rXScgLz4KICAgICAgICA8L2dyb3VwZmlsdGVyPgogICAgICA8L2dyb3VwPgogICAgICA8Y29sdW1uIGNhcHRpb249J0FjdGlvbiAoWUVBUihPcmRlciBEYXRlKSxNT05USChPcmRlciBEYXRlKSknIGRhdGF0eXBlPSd0dXBsZScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChZRUFSKE9yZGVyIERhdGUpLE1PTlRIKE9yZGVyIERhdGUpKV0nIHJvbGU9J2RpbWVuc2lvbicgdHlwZT0nbm9taW5hbCcgdXNlcjphdXRvLWNvbHVtbj0nc2hlZXRfbGluaycgLz4KICAgICAgPGdyb3VwIGNhcHRpb249J0FjdGlvbiAoWUVBUihPcmRlciBEYXRlKSxNT05USChPcmRlciBEYXRlKSxQcm9kdWN0IENhdGVnb3J5KScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChZRUFSKE9yZGVyIERhdGUpLE1PTlRIKE9yZGVyIERhdGUpLFByb2R1Y3QgQ2F0ZWdvcnkpXScgbmFtZS1zdHlsZT0ndW5xdWFsaWZpZWQnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnPgogICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nY3Jvc3Nqb2luJz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1t5cjpPcmRlciBEYXRlOm9rXScgLz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1ttbjpPcmRlciBEYXRlOm9rXScgLz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1tub25lOkNhdGVnb3J5Om5rXScgLz4KICAgICAgICA8L2dyb3VwZmlsdGVyPgogICAgICA8L2dyb3VwPgogICAgICA8Y29sdW1uIGNhcHRpb249J0FjdGlvbiAoWUVBUihPcmRlciBEYXRlKSxNT05USChPcmRlciBEYXRlKSxQcm9kdWN0IENhdGVnb3J5KScgZGF0YXR5cGU9J3R1cGxlJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKFlFQVIoT3JkZXIgRGF0ZSksTU9OVEgoT3JkZXIgRGF0ZSksUHJvZHVjdCBDYXRlZ29yeSldJyByb2xlPSdkaW1lbnNpb24nIHR5cGU9J25vbWluYWwnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbT3JkZXIgRGF0ZV0nIGRlcml2YXRpb249J01vbnRoJyBuYW1lPSdbbW46T3JkZXIgRGF0ZTpva10nIHBpdm90PSdrZXknIHR5cGU9J29yZGluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbQ2F0ZWdvcnldJyBkZXJpdmF0aW9uPSdOb25lJyBuYW1lPSdbbm9uZTpDYXRlZ29yeTpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbT3JkZXIgRGF0ZV0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOk9yZGVyIERhdGU6cWtdJyBwaXZvdD0na2V5JyB0eXBlPSdxdWFudGl0YXRpdmUnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbUmVnaW9uXScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6UmVnaW9uOm5rXScgcGl2b3Q9J2tleScgdHlwZT0nbm9taW5hbCcgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tPcmRlciBEYXRlXScgZGVyaXZhdGlvbj0nWWVhcicgbmFtZT0nW3lyOk9yZGVyIERhdGU6b2tdJyBwaXZvdD0na2V5JyB0eXBlPSdvcmRpbmFsJyAvPgogICAgPC9kYXRhc291cmNlPgogIDwvZGF0YXNvdXJjZXM-CiAgPHdvcmtzaGVldCBuYW1lPSdQcm9kdWN0Vmlldyc-CiAgICA8dGFibGUgLz4KICA8L3dvcmtzaGVldD4KICA8d29ya3NoZWV0IG5hbWU9J1Byb2R1Y3REZXRhaWxzJz4KICAgIDx0YWJsZSAvPgogIDwvd29ya3NoZWV0Pgo8L2N1c3RvbWl6ZWQtdmlldz4K"
+ },
+ {
+ "isSourceView": false,
+ "viewName": "Customers",
+ "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IGRhc2hib2FyZD0nQ3VzdG9tZXJzJyBzb3VyY2UtYnVpbGQ9JzIwMjQuMi4wICgyMDI0Mi4yNC4wNzE2LjE5NDQpJyB2ZXJzaW9uPScxOC4xJyB4bWxuczp1c2VyPSdodHRwOi8vd3d3LnRhYmxlYXVzb2Z0d2FyZS5jb20veG1sL3VzZXInPgogIDxhY3RpdmUgaWQ9Jy0xJyAvPgogIDxkYXRhc291cmNlcz4KICAgIDxkYXRhc291cmNlIG5hbWU9J2ZlZGVyYXRlZC4xMG5uazhkMXZnbXc4cTE3eXU3NnUwNnBuYmNqJz4KICAgICAgPGNvbHVtbiBkYXRhdHlwZT0nc3RyaW5nJyBuYW1lPSdbOk1lYXN1cmUgTmFtZXNdJyByb2xlPSdkaW1lbnNpb24nIHR5cGU9J25vbWluYWwnPgogICAgICAgIDxhbGlhc2VzPgogICAgICAgICAgPGFsaWFzIGtleT0nJnF1b3Q7W2ZlZGVyYXRlZC4xMG5uazhkMXZnbXc4cTE3eXU3NnUwNnBuYmNqXS5bY3RkOkN1c3RvbWVyIE5hbWU6cWtdJnF1b3Q7JyB2YWx1ZT0nQ291bnQgb2YgQ3VzdG9tZXJzJyAvPgogICAgICAgIDwvYWxpYXNlcz4KICAgICAgPC9jb2x1bW4-CiAgICAgIDxncm91cCBjYXB0aW9uPSdBY3Rpb24gKFJlZ2lvbiknIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoUmVnaW9uKV0nIG5hbWUtc3R5bGU9J3VucXVhbGlmaWVkJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJz4KICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2Nyb3Nzam9pbic-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbUmVnaW9uXScgLz4KICAgICAgICA8L2dyb3VwZmlsdGVyPgogICAgICA8L2dyb3VwPgogICAgICA8Y29sdW1uIGNhcHRpb249J0FjdGlvbiAoUmVnaW9uKScgZGF0YXR5cGU9J3R1cGxlJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKFJlZ2lvbildJyByb2xlPSdkaW1lbnNpb24nIHR5cGU9J25vbWluYWwnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbQ2F0ZWdvcnldJyBkZXJpdmF0aW9uPSdOb25lJyBuYW1lPSdbbm9uZTpDYXRlZ29yeTpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbT3JkZXIgRGF0ZV0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOk9yZGVyIERhdGU6cWtdJyBwaXZvdD0na2V5JyB0eXBlPSdxdWFudGl0YXRpdmUnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbUmVnaW9uXScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6UmVnaW9uOm5rXScgcGl2b3Q9J2tleScgdHlwZT0nbm9taW5hbCcgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tTZWdtZW50XScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6U2VnbWVudDpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbT3JkZXIgRGF0ZV0nIGRlcml2YXRpb249J1F1YXJ0ZXInIG5hbWU9J1txcjpPcmRlciBEYXRlOm9rXScgcGl2b3Q9J2tleScgdHlwZT0nb3JkaW5hbCcgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tPcmRlciBEYXRlXScgZGVyaXZhdGlvbj0nWWVhcicgbmFtZT0nW3lyOk9yZGVyIERhdGU6b2tdJyBwaXZvdD0na2V5JyB0eXBlPSdvcmRpbmFsJyAvPgogICAgPC9kYXRhc291cmNlPgogIDwvZGF0YXNvdXJjZXM-CiAgPHdvcmtzaGVldCBuYW1lPSdDdXN0b21lclNjYXR0ZXInPgogICAgPHRhYmxlIC8-CiAgPC93b3Jrc2hlZXQ-CiAgPHdvcmtzaGVldCBuYW1lPSdDdXN0b21lclJhbmsnPgogICAgPHRhYmxlIC8-CiAgPC93b3Jrc2hlZXQ-CiAgPHdvcmtzaGVldCBuYW1lPSdDdXN0b21lck92ZXJ2aWV3Jz4KICAgIDx0YWJsZSAvPgogIDwvd29ya3NoZWV0Pgo8L2N1c3RvbWl6ZWQtdmlldz4K"
+ },
+ {
+ "isSourceView": false,
+ "viewName": "Shipping",
+ "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IGRhc2hib2FyZD0nU2hpcHBpbmcnIHNvdXJjZS1idWlsZD0nMjAyNC4yLjAgKDIwMjQyLjI0LjA3MTYuMTk0NCknIHZlcnNpb249JzE4LjEnIHhtbG5zOnVzZXI9J2h0dHA6Ly93d3cudGFibGVhdXNvZnR3YXJlLmNvbS94bWwvdXNlcic-CiAgPGFjdGl2ZSBpZD0nLTEnIC8-CiAgPGRhdGFzb3VyY2VzPgogICAgPGRhdGFzb3VyY2UgbmFtZT0nZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2onPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChEZWxheWVkPyknIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoRGVsYXllZD8pXScgbmFtZS1zdHlsZT0ndW5xdWFsaWZpZWQnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnPgogICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nY3Jvc3Nqb2luJz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1tDYWxjdWxhdGlvbl82NDAxMTAzMTcxMjU5NzIzXScgLz4KICAgICAgICA8L2dyb3VwZmlsdGVyPgogICAgICA8L2dyb3VwPgogICAgICA8Y29sdW1uIGNhcHRpb249J0FjdGlvbiAoRGVsYXllZD8pJyBkYXRhdHlwZT0ndHVwbGUnIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoRGVsYXllZD8pXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJyAvPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChTaGlwIFN0YXR1cyknIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoU2hpcCBTdGF0dXMpXScgbmFtZS1zdHlsZT0ndW5xdWFsaWZpZWQnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnPgogICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nY3Jvc3Nqb2luJz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1tDYWxjdWxhdGlvbl82NDAxMTAzMTcxMjU5NzIzXScgLz4KICAgICAgICA8L2dyb3VwZmlsdGVyPgogICAgICA8L2dyb3VwPgogICAgICA8Y29sdW1uIGNhcHRpb249J0FjdGlvbiAoU2hpcCBTdGF0dXMpJyBkYXRhdHlwZT0ndHVwbGUnIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoU2hpcCBTdGF0dXMpXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJyAvPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChTaGlwIFN0YXR1cyxZRUFSKE9yZGVyIERhdGUpLFdFRUsoT3JkZXIgRGF0ZSkpJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKFNoaXAgU3RhdHVzLFlFQVIoT3JkZXIgRGF0ZSksV0VFSyhPcmRlciBEYXRlKSldJyBuYW1lLXN0eWxlPSd1bnF1YWxpZmllZCcgdXNlcjphdXRvLWNvbHVtbj0nc2hlZXRfbGluayc-CiAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdjcm9zc2pvaW4nPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW0NhbGN1bGF0aW9uXzY0MDExMDMxNzEyNTk3MjNdJyAvPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW3lyOk9yZGVyIERhdGU6b2tdJyAvPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW3R3azpPcmRlciBEYXRlOm9rXScgLz4KICAgICAgICA8L2dyb3VwZmlsdGVyPgogICAgICA8L2dyb3VwPgogICAgICA8Y29sdW1uIGNhcHRpb249J0FjdGlvbiAoU2hpcCBTdGF0dXMsWUVBUihPcmRlciBEYXRlKSxXRUVLKE9yZGVyIERhdGUpKScgZGF0YXR5cGU9J3R1cGxlJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKFNoaXAgU3RhdHVzLFlFQVIoT3JkZXIgRGF0ZSksV0VFSyhPcmRlciBEYXRlKSldJyByb2xlPSdkaW1lbnNpb24nIHR5cGU9J25vbWluYWwnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbQ2FsY3VsYXRpb25fNjQwMTEwMzE3MTI1OTcyM10nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOkNhbGN1bGF0aW9uXzY0MDExMDMxNzEyNTk3MjM6bmtdJyBwaXZvdD0na2V5JyB0eXBlPSdub21pbmFsJyAvPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW1JlZ2lvbl0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOlJlZ2lvbjpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbU2hpcCBNb2RlXScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6U2hpcCBNb2RlOm5rXScgcGl2b3Q9J2tleScgdHlwZT0nbm9taW5hbCcgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tPcmRlciBEYXRlXScgZGVyaXZhdGlvbj0nUXVhcnRlcicgbmFtZT0nW3FyOk9yZGVyIERhdGU6b2tdJyBwaXZvdD0na2V5JyB0eXBlPSdvcmRpbmFsJyAvPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW09yZGVyIERhdGVdJyBkZXJpdmF0aW9uPSdZZWFyJyBuYW1lPSdbeXI6T3JkZXIgRGF0ZTpva10nIHBpdm90PSdrZXknIHR5cGU9J29yZGluYWwnIC8-CiAgICA8L2RhdGFzb3VyY2U-CiAgPC9kYXRhc291cmNlcz4KICA8d29ya3NoZWV0IG5hbWU9J1NoaXBTdW1tYXJ5Jz4KICAgIDx0YWJsZSAvPgogIDwvd29ya3NoZWV0PgogIDx3b3Jrc2hlZXQgbmFtZT0nU2hpcHBpbmdUcmVuZCc-CiAgICA8dGFibGUgLz4KICA8L3dvcmtzaGVldD4KICA8d29ya3NoZWV0IG5hbWU9J0RheXN0b1NoaXAnPgogICAgPHRhYmxlIC8-CiAgPC93b3Jrc2hlZXQ-CjwvY3VzdG9taXplZC12aWV3Pgo="
+ },
+ {
+ "isSourceView": false,
+ "viewName": "Performance",
+ "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IHNvdXJjZS1idWlsZD0nMjAyNC4yLjAgKDIwMjQyLjI0LjA3MTYuMTk0NCknIHZlcnNpb249JzE4LjEnIHhtbG5zOnVzZXI9J2h0dHA6Ly93d3cudGFibGVhdXNvZnR3YXJlLmNvbS94bWwvdXNlcic-CiAgPGRhdGFzb3VyY2VzPgogICAgPGRhdGFzb3VyY2UgbmFtZT0nZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2onPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW1JlZ2lvbl0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOlJlZ2lvbjpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbT3JkZXIgRGF0ZV0nIGRlcml2YXRpb249J1llYXInIG5hbWU9J1t5cjpPcmRlciBEYXRlOm9rXScgcGl2b3Q9J2tleScgdHlwZT0nb3JkaW5hbCcgLz4KICAgIDwvZGF0YXNvdXJjZT4KICA8L2RhdGFzb3VyY2VzPgogIDx3b3Jrc2hlZXQgbmFtZT0nUGVyZm9ybWFuY2UnPgogICAgPHRhYmxlIC8-CiAgPC93b3Jrc2hlZXQ-CjwvY3VzdG9taXplZC12aWV3Pgo="
+ },
+ {
+ "isSourceView": false,
+ "viewName": "Commission Model",
+ "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IGRhc2hib2FyZD0nQ29tbWlzc2lvbiBNb2RlbCcgc291cmNlLWJ1aWxkPScyMDI0LjIuMCAoMjAyNDIuMjQuMDcxNi4xOTQ0KScgdmVyc2lvbj0nMTguMScgeG1sbnM6dXNlcj0naHR0cDovL3d3dy50YWJsZWF1c29mdHdhcmUuY29tL3htbC91c2VyJz4KICA8YWN0aXZlIGlkPSctMScgLz4KICA8ZGF0YXNvdXJjZXM-CiAgICA8ZGF0YXNvdXJjZSBuYW1lPSdmZWRlcmF0ZWQuMGEwMWNvZDFveGw4M2wxZjV5dmVzMWNmY2lxbyc-CiAgICAgIDxjb2x1bW4gZGF0YXR5cGU9J3N0cmluZycgbmFtZT0nWzpNZWFzdXJlIE5hbWVzXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJyAvPgogICAgPC9kYXRhc291cmNlPgogIDwvZGF0YXNvdXJjZXM-CiAgPHdvcmtzaGVldCBuYW1lPSdRdW90YUF0dGFpbm1lbnQnPgogICAgPHRhYmxlIC8-CiAgPC93b3Jrc2hlZXQ-CiAgPHdvcmtzaGVldCBuYW1lPSdDb21taXNzaW9uUHJvamVjdGlvbic-CiAgICA8dGFibGUgLz4KICA8L3dvcmtzaGVldD4KICA8d29ya3NoZWV0IG5hbWU9J1NhbGVzJz4KICAgIDx0YWJsZSAvPgogIDwvd29ya3NoZWV0PgogIDx3b3Jrc2hlZXQgbmFtZT0nT1RFJz4KICAgIDx0YWJsZSAvPgogIDwvd29ya3NoZWV0Pgo8L2N1c3RvbWl6ZWQtdmlldz4K"
+ },
+ {
+ "isSourceView": false,
+ "viewName": "Order Details",
+ "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IGRhc2hib2FyZD0nT3JkZXIgRGV0YWlscycgc291cmNlLWJ1aWxkPScyMDI0LjIuMCAoMjAyNDIuMjQuMDcxNi4xOTQ0KScgdmVyc2lvbj0nMTguMScgeG1sbnM6dXNlcj0naHR0cDovL3d3dy50YWJsZWF1c29mdHdhcmUuY29tL3htbC91c2VyJz4KICA8YWN0aXZlIGlkPSctMScgLz4KICA8ZGF0YXNvdXJjZXM-CiAgICA8ZGF0YXNvdXJjZSBuYW1lPSdmZWRlcmF0ZWQuMTBubms4ZDF2Z213OHExN3l1NzZ1MDZwbmJjaic-CiAgICAgIDxjb2x1bW4gZGF0YXR5cGU9J3N0cmluZycgbmFtZT0nWzpNZWFzdXJlIE5hbWVzXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJz4KICAgICAgICA8YWxpYXNlcz4KICAgICAgICAgIDxhbGlhcyBrZXk9JyZxdW90O1tmZWRlcmF0ZWQuMTBubms4ZDF2Z213OHExN3l1NzZ1MDZwbmJjal0uW2N0ZDpDdXN0b21lciBOYW1lOnFrXSZxdW90OycgdmFsdWU9J0NvdW50IG9mIEN1c3RvbWVycycgLz4KICAgICAgICA8L2FsaWFzZXM-CiAgICAgIDwvY29sdW1uPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChPcmRlciBQcm9maXRhYmxlPyxNT05USChPcmRlciBEYXRlKSxTZWdtZW50KScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChPcmRlciBQcm9maXRhYmxlPyxNT05USChPcmRlciBEYXRlKSxTZWdtZW50KV0nIG5hbWUtc3R5bGU9J3VucXVhbGlmaWVkJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJz4KICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2Nyb3Nzam9pbic-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbQ2FsY3VsYXRpb25fOTA2MDEyMjEwNDk0NzQ3MV0nIC8-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbdG1uOk9yZGVyIERhdGU6b2tdJyAvPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW1NlZ21lbnRdJyAvPgogICAgICAgIDwvZ3JvdXBmaWx0ZXI-CiAgICAgIDwvZ3JvdXA-CiAgICAgIDxjb2x1bW4gY2FwdGlvbj0nQWN0aW9uIChPcmRlciBQcm9maXRhYmxlPyxNT05USChPcmRlciBEYXRlKSxTZWdtZW50KScgZGF0YXR5cGU9J3R1cGxlJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKE9yZGVyIFByb2ZpdGFibGU_LE1PTlRIKE9yZGVyIERhdGUpLFNlZ21lbnQpXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJyAvPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChQb3N0YWwgQ29kZSxTdGF0ZS9Qcm92aW5jZSkgMScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChQb3N0YWwgQ29kZSxTdGF0ZS9Qcm92aW5jZSkgMV0nIG5hbWUtc3R5bGU9J3VucXVhbGlmaWVkJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJz4KICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2Nyb3Nzam9pbic-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbUG9zdGFsIENvZGVdJyAvPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW1N0YXRlL1Byb3ZpbmNlXScgLz4KICAgICAgICA8L2dyb3VwZmlsdGVyPgogICAgICA8L2dyb3VwPgogICAgICA8Y29sdW1uIGNhcHRpb249J0FjdGlvbiAoUG9zdGFsIENvZGUsU3RhdGUvUHJvdmluY2UpIDEnIGRhdGF0eXBlPSd0dXBsZScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChQb3N0YWwgQ29kZSxTdGF0ZS9Qcm92aW5jZSkgMV0nIHJvbGU9J2RpbWVuc2lvbicgdHlwZT0nbm9taW5hbCcgdXNlcjphdXRvLWNvbHVtbj0nc2hlZXRfbGluaycgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tDYXRlZ29yeV0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOkNhdGVnb3J5Om5rXScgcGl2b3Q9J2tleScgdHlwZT0nbm9taW5hbCcgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tDaXR5XScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6Q2l0eTpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbT3JkZXIgRGF0ZV0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOk9yZGVyIERhdGU6b2tdJyBwaXZvdD0na2V5JyB0eXBlPSdvcmRpbmFsJyAvPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW09yZGVyIERhdGVdJyBkZXJpdmF0aW9uPSdOb25lJyBuYW1lPSdbbm9uZTpPcmRlciBEYXRlOnFrXScgcGl2b3Q9J2tleScgdHlwZT0ncXVhbnRpdGF0aXZlJyAvPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW1JlZ2lvbl0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOlJlZ2lvbjpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbU2VnbWVudF0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOlNlZ21lbnQ6bmtdJyBwaXZvdD0na2V5JyB0eXBlPSdub21pbmFsJyAvPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW1N0YXRlL1Byb3ZpbmNlXScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6U3RhdGUvUHJvdmluY2U6bmtdJyBwaXZvdD0na2V5JyB0eXBlPSdub21pbmFsJyAvPgogICAgPC9kYXRhc291cmNlPgogIDwvZGF0YXNvdXJjZXM-CiAgPHdvcmtzaGVldCBuYW1lPSdQcm9kdWN0IERldGFpbCBTaGVldCc-CiAgICA8dGFibGUgLz4KICA8L3dvcmtzaGVldD4KPC9jdXN0b21pemVkLXZpZXc-Cg=="
+ },
+ {
+ "isSourceView": false,
+ "viewName": "Forecast",
+ "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IHNvdXJjZS1idWlsZD0nMjAyNC4yLjAgKDIwMjQyLjI0LjA3MTYuMTk0NCknIHZlcnNpb249JzE4LjEnIHhtbG5zOnVzZXI9J2h0dHA6Ly93d3cudGFibGVhdXNvZnR3YXJlLmNvbS94bWwvdXNlcic-CiAgPGRhdGFzb3VyY2VzPgogICAgPGRhdGFzb3VyY2UgbmFtZT0nZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2onPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW09yZGVyIERhdGVdJyBkZXJpdmF0aW9uPSdOb25lJyBuYW1lPSdbbm9uZTpPcmRlciBEYXRlOnFrXScgcGl2b3Q9J2tleScgdHlwZT0ncXVhbnRpdGF0aXZlJyAvPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW1JlZ2lvbl0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOlJlZ2lvbjpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICA8L2RhdGFzb3VyY2U-CiAgPC9kYXRhc291cmNlcz4KICA8d29ya3NoZWV0IG5hbWU9J0ZvcmVjYXN0Jz4KICAgIDx0YWJsZSAvPgogIDwvd29ya3NoZWV0Pgo8L2N1c3RvbWl6ZWQtdmlldz4K"
+ },
+ {
+ "isSourceView": false,
+ "viewName": "What If Forecast",
+ "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IHNvdXJjZS1idWlsZD0nMjAyNC4yLjAgKDIwMjQyLjI0LjA3MTYuMTk0NCknIHZlcnNpb249JzE4LjEnIHhtbG5zOnVzZXI9J2h0dHA6Ly93d3cudGFibGVhdXNvZnR3YXJlLmNvbS94bWwvdXNlcic-CiAgPGRhdGFzb3VyY2VzPgogICAgPGRhdGFzb3VyY2UgbmFtZT0nZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2onPgogICAgICA8Y29sdW1uIGRhdGF0eXBlPSdzdHJpbmcnIG5hbWU9J1s6TWVhc3VyZSBOYW1lc10nIHJvbGU9J2RpbWVuc2lvbicgdHlwZT0nbm9taW5hbCc-CiAgICAgICAgPGFsaWFzZXM-CiAgICAgICAgICA8YWxpYXMga2V5PScmcXVvdDtbZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLltjdGQ6Q3VzdG9tZXIgTmFtZTpxa10mcXVvdDsnIHZhbHVlPSdDb3VudCBvZiBDdXN0b21lcnMnIC8-CiAgICAgICAgPC9hbGlhc2VzPgogICAgICA8L2NvbHVtbj4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tPcmRlciBEYXRlXScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6T3JkZXIgRGF0ZTpxa10nIHBpdm90PSdrZXknIHR5cGU9J3F1YW50aXRhdGl2ZScgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tSZWdpb25dJyBkZXJpdmF0aW9uPSdOb25lJyBuYW1lPSdbbm9uZTpSZWdpb246bmtdJyBwaXZvdD0na2V5JyB0eXBlPSdub21pbmFsJyAvPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW09yZGVyIERhdGVdJyBkZXJpdmF0aW9uPSdZZWFyJyBuYW1lPSdbeXI6T3JkZXIgRGF0ZTpva10nIHBpdm90PSdrZXknIHR5cGU9J29yZGluYWwnIC8-CiAgICA8L2RhdGFzb3VyY2U-CiAgPC9kYXRhc291cmNlcz4KICA8d29ya3NoZWV0IG5hbWU9J1doYXQgSWYgRm9yZWNhc3QnPgogICAgPHRhYmxlIC8-CiAgPC93b3Jrc2hlZXQ-CjwvY3VzdG9taXplZC12aWV3Pgo="
+ }
+]
\ No newline at end of file
diff --git a/test/assets/custom_view_get.xml b/test/assets/custom_view_get.xml
new file mode 100644
index 000000000..67e342f30
--- /dev/null
+++ b/test/assets/custom_view_get.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/assets/custom_view_get_id.xml b/test/assets/custom_view_get_id.xml
new file mode 100644
index 000000000..14e589b8d
--- /dev/null
+++ b/test/assets/custom_view_get_id.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/test/assets/custom_view_update.xml b/test/assets/custom_view_update.xml
new file mode 100644
index 000000000..5ab85bc05
--- /dev/null
+++ b/test/assets/custom_view_update.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/test/assets/datasource_get.xml b/test/assets/datasource_get.xml
index 951409caa..1c420d116 100644
--- a/test/assets/datasource_get.xml
+++ b/test/assets/datasource_get.xml
@@ -2,12 +2,12 @@
-
+
-
+
@@ -17,4 +17,4 @@
-
\ No newline at end of file
+
diff --git a/test/assets/datasource_get_all_fields.xml b/test/assets/datasource_get_all_fields.xml
new file mode 100644
index 000000000..46c4396d3
--- /dev/null
+++ b/test/assets/datasource_get_all_fields.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/test/assets/datasource_revision.xml b/test/assets/datasource_revision.xml
new file mode 100644
index 000000000..8cadafc8f
--- /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/flow_populate_permissions.xml b/test/assets/flow_populate_permissions.xml
index 59fe5bd67..ce3a22f97 100644
--- a/test/assets/flow_populate_permissions.xml
+++ b/test/assets/flow_populate_permissions.xml
@@ -11,5 +11,12 @@
+
+
+
+
+
+
+
-
\ No newline at end of file
+
diff --git a/test/assets/flow_publish.xml b/test/assets/flow_publish.xml
new file mode 100644
index 000000000..55af88d11
--- /dev/null
+++ b/test/assets/flow_publish.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/test/assets/flow_runs_get.xml b/test/assets/flow_runs_get.xml
index bdce4cdfb..489e8ac63 100644
--- a/test/assets/flow_runs_get.xml
+++ b/test/assets/flow_runs_get.xml
@@ -1,5 +1,4 @@
-
-
\ No newline at end of file
+
diff --git a/test/assets/group_add_users.xml b/test/assets/group_add_users.xml
new file mode 100644
index 000000000..23fd7bd9f
--- /dev/null
+++ b/test/assets/group_add_users.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/test/assets/group_get_all_fields.xml b/test/assets/group_get_all_fields.xml
new file mode 100644
index 000000000..0118250e1
--- /dev/null
+++ b/test/assets/group_get_all_fields.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/assets/group_update_async.xml b/test/assets/group_update_async.xml
new file mode 100644
index 000000000..ea6b47eaa
--- /dev/null
+++ b/test/assets/group_update_async.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/test/assets/groupsets_create.xml b/test/assets/groupsets_create.xml
new file mode 100644
index 000000000..233b0f939
--- /dev/null
+++ b/test/assets/groupsets_create.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/test/assets/groupsets_get.xml b/test/assets/groupsets_get.xml
new file mode 100644
index 000000000..ff3bec1fb
--- /dev/null
+++ b/test/assets/groupsets_get.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/assets/groupsets_get_by_id.xml b/test/assets/groupsets_get_by_id.xml
new file mode 100644
index 000000000..558e4d870
--- /dev/null
+++ b/test/assets/groupsets_get_by_id.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/test/assets/groupsets_update.xml b/test/assets/groupsets_update.xml
new file mode 100644
index 000000000..b64fa6ea1
--- /dev/null
+++ b/test/assets/groupsets_update.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/test/assets/job_get_by_id_completed.xml b/test/assets/job_get_by_id_completed.xml
new file mode 100644
index 000000000..95ca29b49
--- /dev/null
+++ b/test/assets/job_get_by_id_completed.xml
@@ -0,0 +1,14 @@
+
+
+
+
+ Job detail notes
+
+
+ More detail
+
+
+
\ No newline at end of file
diff --git a/test/assets/job_get_by_id_failed_workbook.xml b/test/assets/job_get_by_id_failed_workbook.xml
new file mode 100644
index 000000000..bf81d896e
--- /dev/null
+++ b/test/assets/job_get_by_id_failed_workbook.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+ java.lang.RuntimeException: [Microsoft][ODBC Driver 17 for SQL Server][SQL Server]Login failed for user.\nIntegrated authentication failed.
+
+
+
diff --git a/test/assets/linked_tasks_get.xml b/test/assets/linked_tasks_get.xml
new file mode 100644
index 000000000..23b7bbbbc
--- /dev/null
+++ b/test/assets/linked_tasks_get.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/assets/linked_tasks_run_now.xml b/test/assets/linked_tasks_run_now.xml
new file mode 100644
index 000000000..63cef73b1
--- /dev/null
+++ b/test/assets/linked_tasks_run_now.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/test/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/odata_connection.xml b/test/assets/odata_connection.xml
new file mode 100644
index 000000000..0c16fcca6
--- /dev/null
+++ b/test/assets/odata_connection.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/test/assets/populate_excel.xlsx b/test/assets/populate_excel.xlsx
new file mode 100644
index 000000000..3cf6115c7
Binary files /dev/null and b/test/assets/populate_excel.xlsx differ
diff --git a/test/assets/populate_powerpoint.pptx b/test/assets/populate_powerpoint.pptx
new file mode 100644
index 000000000..dbf979c06
Binary files /dev/null and b/test/assets/populate_powerpoint.pptx differ
diff --git a/test/assets/project_get_all_fields.xml b/test/assets/project_get_all_fields.xml
new file mode 100644
index 000000000..d71ebd922
--- /dev/null
+++ b/test/assets/project_get_all_fields.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/test/assets/project_populate_virtualconnection_default_permissions.xml b/test/assets/project_populate_virtualconnection_default_permissions.xml
new file mode 100644
index 000000000..10678f794
--- /dev/null
+++ b/test/assets/project_populate_virtualconnection_default_permissions.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/assets/project_update.xml b/test/assets/project_update.xml
index eaa884627..f2485c898 100644
--- a/test/assets/project_update.xml
+++ b/test/assets/project_update.xml
@@ -1,4 +1,6 @@
-
+
+
+
diff --git a/test/assets/project_update_virtualconnection_default_permissions.xml b/test/assets/project_update_virtualconnection_default_permissions.xml
new file mode 100644
index 000000000..10b5ba6ec
--- /dev/null
+++ b/test/assets/project_update_virtualconnection_default_permissions.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/assets/queryset_slicing_page_1.xml b/test/assets/queryset_slicing_page_1.xml
new file mode 100644
index 000000000..be3df91f8
--- /dev/null
+++ b/test/assets/queryset_slicing_page_1.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/assets/queryset_slicing_page_2.xml b/test/assets/queryset_slicing_page_2.xml
new file mode 100644
index 000000000..058bbd5c0
--- /dev/null
+++ b/test/assets/queryset_slicing_page_2.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/assets/request_option_filter_name_in.xml b/test/assets/request_option_filter_name_in.xml
new file mode 100644
index 000000000..9ec42b8ab
--- /dev/null
+++ b/test/assets/request_option_filter_name_in.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/assets/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.xml b/test/assets/schedule_get.xml
index 66e4d6e51..db5e1a05e 100644
--- a/test/assets/schedule_get.xml
+++ b/test/assets/schedule_get.xml
@@ -5,5 +5,6 @@
+
\ No newline at end of file
diff --git a/test/assets/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/schedule_get_daily_id.xml b/test/assets/schedule_get_daily_id.xml
new file mode 100644
index 000000000..99467a391
--- /dev/null
+++ b/test/assets/schedule_get_daily_id.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/assets/schedule_get_extract_refresh_tasks.xml b/test/assets/schedule_get_extract_refresh_tasks.xml
new file mode 100644
index 000000000..48906dde6
--- /dev/null
+++ b/test/assets/schedule_get_extract_refresh_tasks.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/assets/schedule_get_hourly_id.xml b/test/assets/schedule_get_hourly_id.xml
new file mode 100644
index 000000000..27c374ccf
--- /dev/null
+++ b/test/assets/schedule_get_hourly_id.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/assets/schedule_get_monthly_id.xml b/test/assets/schedule_get_monthly_id.xml
new file mode 100644
index 000000000..3fc32cc57
--- /dev/null
+++ b/test/assets/schedule_get_monthly_id.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/assets/schedule_get_monthly_id_2.xml b/test/assets/schedule_get_monthly_id_2.xml
new file mode 100644
index 000000000..ca84297e7
--- /dev/null
+++ b/test/assets/schedule_get_monthly_id_2.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/assets/server_info_get.xml b/test/assets/server_info_get.xml
index ce4e0b322..94218502a 100644
--- a/test/assets/server_info_get.xml
+++ b/test/assets/server_info_get.xml
@@ -1,6 +1,6 @@
10.1.0
-2.4
+3.10
-
\ No newline at end of file
+
diff --git a/test/assets/server_info_wrong_site.html b/test/assets/server_info_wrong_site.html
new file mode 100644
index 000000000..e92daeb2d
--- /dev/null
+++ b/test/assets/server_info_wrong_site.html
@@ -0,0 +1,56 @@
+
+
+
+
+
+ Example website
+
+
+
+
+
+ | A |
+ B |
+ C |
+ D |
+ E |
+
+
+ 1 |
+ 2 |
+ 3 |
+ 4 |
+ 5 |
+
+
+ 2 |
+ 3 |
+ 4 |
+ 5 |
+ 6 |
+
+
+ 3 |
+ 4 |
+ 5 |
+ 6 |
+ 7 |
+
+
+ 4 |
+ 5 |
+ 6 |
+ 7 |
+ 8 |
+
+
+ 5 |
+ 6 |
+ 7 |
+ 8 |
+ 9 |
+
+
+
+
+
\ No newline at end of file
diff --git a/test/assets/site_auth_configurations.xml b/test/assets/site_auth_configurations.xml
new file mode 100644
index 000000000..c81d179ac
--- /dev/null
+++ b/test/assets/site_auth_configurations.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/test/assets/site_get_by_id.xml b/test/assets/site_get_by_id.xml
index a47703fb6..a8a1e9a5c 100644
--- a/test/assets/site_get_by_id.xml
+++ b/test/assets/site_get_by_id.xml
@@ -1,4 +1,4 @@
-
-
\ No newline at end of file
+
+
diff --git a/test/assets/site_get_by_name.xml b/test/assets/site_get_by_name.xml
index 852f9594f..b7ae2b595 100644
--- a/test/assets/site_get_by_name.xml
+++ b/test/assets/site_get_by_name.xml
@@ -1,4 +1,4 @@
-
-
\ No newline at end of file
+
+
diff --git a/test/assets/site_update.xml b/test/assets/site_update.xml
index dbb166de1..1661a426b 100644
--- a/test/assets/site_update.xml
+++ b/test/assets/site_update.xml
@@ -1,4 +1,4 @@
-
-
\ No newline at end of file
+
+
diff --git a/test/assets/tasks_create_extract_task.xml b/test/assets/tasks_create_extract_task.xml
new file mode 100644
index 000000000..9e6310fba
--- /dev/null
+++ b/test/assets/tasks_create_extract_task.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/assets/tasks_create_flow_task.xml b/test/assets/tasks_create_flow_task.xml
new file mode 100644
index 000000000..11c9a4ff0
--- /dev/null
+++ b/test/assets/tasks_create_flow_task.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/assets/tasks_with_interval.xml b/test/assets/tasks_with_interval.xml
new file mode 100644
index 000000000..a317408fb
--- /dev/null
+++ b/test/assets/tasks_with_interval.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/assets/tasks_without_schedule.xml b/test/assets/tasks_without_schedule.xml
new file mode 100644
index 000000000..e669bf67f
--- /dev/null
+++ b/test/assets/tasks_without_schedule.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/assets/user_get_all_fields.xml b/test/assets/user_get_all_fields.xml
new file mode 100644
index 000000000..7e9a62568
--- /dev/null
+++ b/test/assets/user_get_all_fields.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/assets/view_get_all_fields.xml b/test/assets/view_get_all_fields.xml
new file mode 100644
index 000000000..236ebd726
--- /dev/null
+++ b/test/assets/view_get_all_fields.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/assets/view_get_id_usage.xml b/test/assets/view_get_id_usage.xml
new file mode 100644
index 000000000..a0cdd98db
--- /dev/null
+++ b/test/assets/view_get_id_usage.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/assets/virtual_connection_add_permissions.xml b/test/assets/virtual_connection_add_permissions.xml
new file mode 100644
index 000000000..d8b052848
--- /dev/null
+++ b/test/assets/virtual_connection_add_permissions.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/assets/virtual_connection_database_connection_update.xml b/test/assets/virtual_connection_database_connection_update.xml
new file mode 100644
index 000000000..a6135d604
--- /dev/null
+++ b/test/assets/virtual_connection_database_connection_update.xml
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/test/assets/virtual_connection_populate_connections.xml b/test/assets/virtual_connection_populate_connections.xml
new file mode 100644
index 000000000..77d899520
--- /dev/null
+++ b/test/assets/virtual_connection_populate_connections.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/test/assets/virtual_connection_populate_connections2.xml b/test/assets/virtual_connection_populate_connections2.xml
new file mode 100644
index 000000000..f0ad2646d
--- /dev/null
+++ b/test/assets/virtual_connection_populate_connections2.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/test/assets/virtual_connections_download.xml b/test/assets/virtual_connections_download.xml
new file mode 100644
index 000000000..889e70ce7
--- /dev/null
+++ b/test/assets/virtual_connections_download.xml
@@ -0,0 +1,7 @@
+
+
+
+
+ {"policyCollection":{"luid":"34ae5eb9-ceac-4158-86f1-a5d8163d5261","policies":[]},"revision":{"luid":"1b2e2aae-b904-4f5a-aa4d-9f114b8e5f57","revisableProperties":{}}}
+
+
diff --git a/test/assets/virtual_connections_get.xml b/test/assets/virtual_connections_get.xml
new file mode 100644
index 000000000..f1f410e4c
--- /dev/null
+++ b/test/assets/virtual_connections_get.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
diff --git a/test/assets/virtual_connections_publish.xml b/test/assets/virtual_connections_publish.xml
new file mode 100644
index 000000000..889e70ce7
--- /dev/null
+++ b/test/assets/virtual_connections_publish.xml
@@ -0,0 +1,7 @@
+
+
+
+
+ {"policyCollection":{"luid":"34ae5eb9-ceac-4158-86f1-a5d8163d5261","policies":[]},"revision":{"luid":"1b2e2aae-b904-4f5a-aa4d-9f114b8e5f57","revisableProperties":{}}}
+
+
diff --git a/test/assets/virtual_connections_revisions.xml b/test/assets/virtual_connections_revisions.xml
new file mode 100644
index 000000000..374113427
--- /dev/null
+++ b/test/assets/virtual_connections_revisions.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/assets/virtual_connections_update.xml b/test/assets/virtual_connections_update.xml
new file mode 100644
index 000000000..60d5d1697
--- /dev/null
+++ b/test/assets/virtual_connections_update.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
diff --git a/test/assets/workbook_get_all_fields.xml b/test/assets/workbook_get_all_fields.xml
new file mode 100644
index 000000000..007b79338
--- /dev/null
+++ b/test/assets/workbook_get_all_fields.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/assets/workbook_get_by_id_acceleration_status.xml b/test/assets/workbook_get_by_id_acceleration_status.xml
new file mode 100644
index 000000000..0d1f9b93d
--- /dev/null
+++ b/test/assets/workbook_get_by_id_acceleration_status.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/assets/workbook_publish.xml b/test/assets/workbook_publish.xml
index dcfc79936..3e23bda71 100644
--- a/test/assets/workbook_publish.xml
+++ b/test/assets/workbook_publish.xml
@@ -1,6 +1,6 @@
-
+
@@ -8,4 +8,4 @@
-
\ No newline at end of file
+
diff --git a/test/assets/workbook_revision.xml b/test/assets/workbook_revision.xml
new file mode 100644
index 000000000..8cadafc8f
--- /dev/null
+++ b/test/assets/workbook_revision.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/assets/workbook_update_acceleration_status.xml b/test/assets/workbook_update_acceleration_status.xml
new file mode 100644
index 000000000..7c3366fee
--- /dev/null
+++ b/test/assets/workbook_update_acceleration_status.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/assets/workbook_update_data_freshness_policy.xml b/test/assets/workbook_update_data_freshness_policy.xml
new file mode 100644
index 000000000..a69a097ba
--- /dev/null
+++ b/test/assets/workbook_update_data_freshness_policy.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/assets/workbook_update_data_freshness_policy2.xml b/test/assets/workbook_update_data_freshness_policy2.xml
new file mode 100644
index 000000000..384f79ec0
--- /dev/null
+++ b/test/assets/workbook_update_data_freshness_policy2.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/assets/workbook_update_data_freshness_policy3.xml b/test/assets/workbook_update_data_freshness_policy3.xml
new file mode 100644
index 000000000..195013517
--- /dev/null
+++ b/test/assets/workbook_update_data_freshness_policy3.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/assets/workbook_update_data_freshness_policy4.xml b/test/assets/workbook_update_data_freshness_policy4.xml
new file mode 100644
index 000000000..8208d986a
--- /dev/null
+++ b/test/assets/workbook_update_data_freshness_policy4.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/assets/workbook_update_data_freshness_policy5.xml b/test/assets/workbook_update_data_freshness_policy5.xml
new file mode 100644
index 000000000..b6e0358b6
--- /dev/null
+++ b/test/assets/workbook_update_data_freshness_policy5.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/assets/workbook_update_data_freshness_policy6.xml b/test/assets/workbook_update_data_freshness_policy6.xml
new file mode 100644
index 000000000..c8be8f6c1
--- /dev/null
+++ b/test/assets/workbook_update_data_freshness_policy6.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/assets/workbook_update_views_acceleration_status.xml b/test/assets/workbook_update_views_acceleration_status.xml
new file mode 100644
index 000000000..f2055fb79
--- /dev/null
+++ b/test/assets/workbook_update_views_acceleration_status.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/http/test_http_requests.py b/test/http/test_http_requests.py
new file mode 100644
index 000000000..ce845502d
--- /dev/null
+++ b/test/http/test_http_requests.py
@@ -0,0 +1,117 @@
+import tableauserverclient as TSC
+import unittest
+import requests
+import requests_mock
+
+from unittest import mock
+from requests.exceptions import MissingSchema
+
+
+# This method will be used by the mock to replace requests.get
+def mocked_requests_get(*args, **kwargs):
+ class MockResponse:
+ def __init__(self, status_code):
+ self.headers = {}
+ self.encoding = None
+ self.content = (
+ ""
+ ""
+ "0.31"
+ "0.31"
+ "2022.3"
+ ""
+ ""
+ )
+ self.status_code = status_code
+
+ return MockResponse(200)
+
+
+class ServerTests(unittest.TestCase):
+ def test_init_server_model_empty_throws(self):
+ with self.assertRaises(TypeError):
+ server = TSC.Server()
+
+ def test_init_server_model_no_protocol_defaults_htt(self):
+ server = TSC.Server("fake-url")
+
+ def test_init_server_model_valid_server_name_works(self):
+ server = TSC.Server("http://fake-url")
+
+ def test_init_server_model_valid_https_server_name_works(self):
+ # by default, it will just set the version to 2.3
+ server = TSC.Server("https://fake-url")
+
+ def test_init_server_model_bad_server_name_not_version_check(self):
+ server = TSC.Server("fake-url", use_server_version=False)
+
+ @mock.patch("requests.sessions.Session.get", side_effect=mocked_requests_get)
+ def test_init_server_model_bad_server_name_do_version_check(self, mock_get):
+ server = TSC.Server("fake-url", use_server_version=True)
+
+ def test_init_server_model_bad_server_name_not_version_check_random_options(self):
+ # with self.assertRaises(MissingSchema):
+ server = TSC.Server("fake-url", use_server_version=False, http_options={"foo": 1})
+
+ def test_init_server_model_bad_server_name_not_version_check_real_options(self):
+ # with self.assertRaises(ValueError):
+ server = TSC.Server("fake-url", use_server_version=False, http_options={"verify": False})
+
+ def test_http_options_skip_ssl_works(self):
+ http_options = {"verify": False}
+ server = TSC.Server("http://fake-url")
+ server.add_http_options(http_options)
+
+ def test_http_options_multiple_options_works(self):
+ http_options = {"verify": False, "birdname": "Parrot"}
+ server = TSC.Server("http://fake-url")
+ server.add_http_options(http_options)
+
+ # ValueError: dictionary update sequence element #0 has length 1; 2 is required
+ def test_http_options_multiple_dicts_fails(self):
+ http_options_1 = {"verify": False}
+ http_options_2 = {"birdname": "Parrot"}
+ server = TSC.Server("http://fake-url")
+ with self.assertRaises(ValueError):
+ server.add_http_options([http_options_1, http_options_2])
+
+ # TypeError: cannot convert dictionary update sequence element #0 to a sequence
+ def test_http_options_not_sequence_fails(self):
+ server = TSC.Server("http://fake-url")
+ with self.assertRaises(ValueError):
+ server.add_http_options({1, 2, 3})
+
+ def test_validate_connection_http(self):
+ url = "http://cookies.com"
+ server = TSC.Server(url)
+ server.validate_connection_settings()
+ self.assertEqual(url, server.server_address)
+
+ def test_validate_connection_https(self):
+ url = "https://cookies.com"
+ server = TSC.Server(url)
+ server.validate_connection_settings()
+ self.assertEqual(url, server.server_address)
+
+ def test_validate_connection_no_protocol(self):
+ url = "cookies.com"
+ fixed_url = "http://cookies.com"
+ server = TSC.Server(url)
+ server.validate_connection_settings()
+ self.assertEqual(fixed_url, server.server_address)
+
+
+class SessionTests(unittest.TestCase):
+ test_header = {"x-test": "true"}
+
+ @staticmethod
+ def session_factory():
+ session = requests.session()
+ session.headers.update(SessionTests.test_header)
+ return session
+
+ def test_session_factory_adds_headers(self):
+ test_request_bin = "http://capture-this-with-mock.com"
+ with requests_mock.mock() as m:
+ m.get(url="http://capture-this-with-mock.com/api/2.4/serverInfo", request_headers=SessionTests.test_header)
+ server = TSC.Server(test_request_bin, use_server_version=True, session_factory=SessionTests.session_factory)
diff --git a/test/models/_models.py b/test/models/_models.py
new file mode 100644
index 000000000..59011c6c3
--- /dev/null
+++ b/test/models/_models.py
@@ -0,0 +1,58 @@
+from tableauserverclient import *
+
+# TODO why aren't these available in the tsc namespace? Probably a bug.
+from tableauserverclient.models import (
+ DataAccelerationReportItem,
+ Credentials,
+ ServerInfoItem,
+ Resource,
+ TableauItem,
+)
+
+
+def get_defined_models():
+ # nothing clever here: list was manually copied from tsc/models/__init__.py
+ return [
+ BackgroundJobItem,
+ ConnectionItem,
+ DataAccelerationReportItem,
+ DataAlertItem,
+ DatasourceItem,
+ FlowItem,
+ GroupItem,
+ JobItem,
+ MetricItem,
+ PermissionsRule,
+ ProjectItem,
+ RevisionItem,
+ ScheduleItem,
+ SubscriptionItem,
+ Credentials,
+ JWTAuth,
+ TableauAuth,
+ PersonalAccessTokenAuth,
+ ServerInfoItem,
+ SiteItem,
+ TaskItem,
+ UserItem,
+ ViewItem,
+ WebhookItem,
+ WorkbookItem,
+ PaginationItem,
+ Permission.Mode,
+ Permission.Capability,
+ DailyInterval,
+ WeeklyInterval,
+ MonthlyInterval,
+ HourlyInterval,
+ TableItem,
+ Target,
+ ]
+
+
+def get_unimplemented_models():
+ return [
+ FavoriteItem, # no repr because there is no state
+ Resource, # list of type names
+ TableauItem, # should be an interface
+ ]
diff --git a/test/models/test_repr.py b/test/models/test_repr.py
new file mode 100644
index 000000000..92d11978f
--- /dev/null
+++ b/test/models/test_repr.py
@@ -0,0 +1,51 @@
+import inspect
+
+from unittest import TestCase
+import _models # type: ignore # did not set types for this
+import tableauserverclient as TSC
+
+from typing import Any
+
+
+# ensure that all models that don't need parameters can be instantiated
+# todo....
+def instantiate_class(name: str, obj: Any):
+ # Get the constructor (init) of the class
+ constructor = getattr(obj, "__init__", None)
+ if constructor:
+ # Get the parameters of the constructor (excluding 'self')
+ parameters = inspect.signature(constructor).parameters.values()
+ required_parameters = [
+ param for param in parameters if param.default == inspect.Parameter.empty and param.name != "self"
+ ]
+ if required_parameters:
+ print(f"Class '{name}' requires the following parameters for instantiation:")
+ for param in required_parameters:
+ print(f"- {param.name}")
+ else:
+ print(f"Class '{name}' does not require any parameters for instantiation.")
+ # Instantiate the class
+ instance = obj()
+ print(f"Instantiated: {name} -> {instance}")
+ else:
+ print(f"Class '{name}' does not have a constructor (__init__ method).")
+
+
+class TestAllModels(TestCase):
+ # not all models have __repr__ yet: see above list
+ def test_repr_is_implemented(self):
+ m = _models.get_defined_models()
+ for model in m:
+ with self.subTest(model.__name__, model=model):
+ print(model.__name__, type(model.__repr__).__name__)
+ self.assertEqual(type(model.__repr__).__name__, "function")
+
+ # 2 - Iterate through the objects in the module
+ def test_by_reflection(self):
+ for class_name, obj in inspect.getmembers(TSC, is_concrete):
+ with self.subTest(class_name, obj=obj):
+ instantiate_class(class_name, obj)
+
+
+def is_concrete(obj: Any):
+ return inspect.isclass(obj) and not inspect.isabstract(obj)
diff --git a/test/request_factory/__init__.py b/test/request_factory/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/test/request_factory/test_datasource_requests.py b/test/request_factory/test_datasource_requests.py
new file mode 100644
index 000000000..75bb535d5
--- /dev/null
+++ b/test/request_factory/test_datasource_requests.py
@@ -0,0 +1,15 @@
+import unittest
+import tableauserverclient as TSC
+import tableauserverclient.server.request_factory as TSC_RF
+from tableauserverclient import DatasourceItem
+
+
+class DatasourceRequestTests(unittest.TestCase):
+ def test_generate_xml(self):
+ datasource_item: TSC.DatasourceItem = TSC.DatasourceItem("name")
+ datasource_item.name = "a ds"
+ datasource_item.description = "described"
+ datasource_item.use_remote_query_agent = False
+ datasource_item.ask_data_enablement = DatasourceItem.AskDataEnablement.Enabled
+ datasource_item.project_id = "testval"
+ TSC_RF.RequestFactory.Datasource._generate_xml(datasource_item)
diff --git a/test/request_factory/test_task_requests.py b/test/request_factory/test_task_requests.py
new file mode 100644
index 000000000..6287fa6ea
--- /dev/null
+++ b/test/request_factory/test_task_requests.py
@@ -0,0 +1,47 @@
+import unittest
+import xml.etree.ElementTree as ET
+from unittest.mock import Mock
+from tableauserverclient.server.request_factory import TaskRequest
+
+
+class TestTaskRequest(unittest.TestCase):
+ def setUp(self):
+ self.task_request = TaskRequest()
+ self.xml_request = ET.Element("tsRequest")
+
+ def test_refresh_req_default(self):
+ result = self.task_request.refresh_req()
+ self.assertEqual(result, ET.tostring(self.xml_request))
+
+ def test_refresh_req_incremental(self):
+ with self.assertRaises(ValueError):
+ self.task_request.refresh_req(incremental=True)
+
+ def test_refresh_req_with_parent_srv_version_3_25(self):
+ parent_srv = Mock()
+ parent_srv.check_at_least_version.return_value = True
+ result = self.task_request.refresh_req(incremental=True, parent_srv=parent_srv)
+ expected_xml = ET.Element("tsRequest")
+ task_element = ET.SubElement(expected_xml, "extractRefresh")
+ task_element.attrib["incremental"] = "true"
+ self.assertEqual(result, ET.tostring(expected_xml))
+
+ def test_refresh_req_with_parent_srv_version_3_25_non_incremental(self):
+ parent_srv = Mock()
+ parent_srv.check_at_least_version.return_value = True
+ result = self.task_request.refresh_req(incremental=False, parent_srv=parent_srv)
+ expected_xml = ET.Element("tsRequest")
+ ET.SubElement(expected_xml, "extractRefresh")
+ self.assertEqual(result, ET.tostring(expected_xml))
+
+ def test_refresh_req_with_parent_srv_version_below_3_25(self):
+ parent_srv = Mock()
+ parent_srv.check_at_least_version.return_value = False
+ with self.assertRaises(ValueError):
+ self.task_request.refresh_req(incremental=True, parent_srv=parent_srv)
+
+ def test_refresh_req_with_parent_srv_version_below_3_25_non_incremental(self):
+ parent_srv = Mock()
+ parent_srv.check_at_least_version.return_value = False
+ result = self.task_request.refresh_req(incremental=False, parent_srv=parent_srv)
+ self.assertEqual(result, ET.tostring(self.xml_request))
diff --git a/test/request_factory/test_workbook_requests.py b/test/request_factory/test_workbook_requests.py
new file mode 100644
index 000000000..332b6defa
--- /dev/null
+++ b/test/request_factory/test_workbook_requests.py
@@ -0,0 +1,55 @@
+import unittest
+import tableauserverclient as TSC
+import tableauserverclient.server.request_factory as TSC_RF
+from tableauserverclient.helpers.strings import redact_xml
+import pytest
+import sys
+
+
+class WorkbookRequestTests(unittest.TestCase):
+ def test_embedded_extract_req(self):
+ include_all = True
+ embedded_datasources = None
+ xml_result = TSC_RF.RequestFactory.Workbook.embedded_extract_req(include_all, embedded_datasources)
+
+ def test_generate_xml(self):
+ workbook_item: TSC.WorkbookItem = TSC.WorkbookItem("name", "project_id")
+ TSC_RF.RequestFactory.Workbook._generate_xml(workbook_item)
+
+ def test_generate_xml_invalid_connection(self):
+ workbook_item: TSC.WorkbookItem = TSC.WorkbookItem("name", "project_id")
+ conn = TSC.ConnectionItem()
+ with self.assertRaises(ValueError):
+ request = TSC_RF.RequestFactory.Workbook._generate_xml(workbook_item, connections=[conn])
+
+ def test_generate_xml_invalid_connection_credentials(self):
+ workbook_item: TSC.WorkbookItem = TSC.WorkbookItem("name", "project_id")
+ conn = TSC.ConnectionItem()
+ conn.server_address = "address"
+ creds = TSC.ConnectionCredentials("username", "password")
+ creds.name = None
+ conn.connection_credentials = creds
+ with self.assertRaises(ValueError):
+ request = TSC_RF.RequestFactory.Workbook._generate_xml(workbook_item, connections=[conn])
+
+ def test_generate_xml_valid_connection_credentials(self):
+ workbook_item: TSC.WorkbookItem = TSC.WorkbookItem("name", "project_id")
+ conn = TSC.ConnectionItem()
+ conn.server_address = "address"
+ creds = TSC.ConnectionCredentials("username", "DELETEME")
+ conn.connection_credentials = creds
+ request = TSC_RF.RequestFactory.Workbook._generate_xml(workbook_item, connections=[conn])
+ assert request.find(b"DELETEME") > 0
+
+ def test_redact_passwords_in_xml(self):
+ if sys.version_info < (3, 7):
+ pytest.skip("Redaction is only implemented for 3.7+.")
+ workbook_item: TSC.WorkbookItem = TSC.WorkbookItem("name", "project_id")
+ conn = TSC.ConnectionItem()
+ conn.server_address = "address"
+ creds = TSC.ConnectionCredentials("username", "DELETEME")
+ conn.connection_credentials = creds
+ request = TSC_RF.RequestFactory.Workbook._generate_xml(workbook_item, connections=[conn])
+ redacted = redact_xml(request)
+ assert request.find(b"DELETEME") > 0, request
+ assert redacted.find(b"DELETEME") == -1, redacted
diff --git a/test/test_auth.py b/test/test_auth.py
index 3dbf87737..09e3e251d 100644
--- a/test/test_auth.py
+++ b/test/test_auth.py
@@ -1,124 +1,133 @@
-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("Samples", self.server.site_url)
+ self.assertEqual("1a96d216-e9b8-497b-a82a-0b899a965e01", self.server.user_id)
def test_sign_in_with_personal_access_tokens(self):
- 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("Samples", self.server.site_url)
+ 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')
- self.assertRaises(TSC.ServerResponseError, self.server.auth.sign_in, tableau_auth)
+ m.post(self.baseurl + "/signin", text=response_xml, status_code=401)
+ tableau_auth = TSC.TableauAuth("testuser", "wrongpassword")
+ self.assertRaises(TSC.FailedSignInError, self.server.auth.sign_in, tableau_auth)
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')
- self.assertRaises(TSC.ServerResponseError, self.server.auth.sign_in, tableau_auth)
+ m.post(self.baseurl + "/signin", text=response_xml, status_code=401)
+ tableau_auth = TSC.PersonalAccessTokenAuth(token_name="mytoken", personal_access_token="invalid")
+ self.assertRaises(TSC.FailedSignInError, self.server.auth.sign_in, tableau_auth)
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('', '')
- self.assertRaises(TSC.ServerResponseError, self.server.auth.sign_in, tableau_auth)
+ m.post(self.baseurl + "/signin", text=response_xml, status_code=401)
+ tableau_auth = TSC.TableauAuth("", "")
+ self.assertRaises(TSC.FailedSignInError, self.server.auth.sign_in, tableau_auth)
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()
self.assertIsNone(self.server._auth_token)
self.assertIsNone(self.server._site_id)
+ self.assertIsNone(self.server._site_url)
self.assertIsNone(self.server._user_id)
def test_switch_site(self):
- 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("Samples", self.server.site_url)
+ 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("Samples", self.server.site_url)
+ self.assertEqual("1a96d216-e9b8-497b-a82a-0b899a965e01", self.server.user_id)
diff --git a/test/test_connection_.py b/test/test_connection_.py
new file mode 100644
index 000000000..47b796ebe
--- /dev/null
+++ b/test/test_connection_.py
@@ -0,0 +1,34 @@
+import unittest
+import tableauserverclient as TSC
+
+
+class DatasourceModelTests(unittest.TestCase):
+ def test_require_boolean_query_tag_fails(self):
+ conn = TSC.ConnectionItem()
+ conn._connection_type = "postgres"
+ with self.assertRaises(ValueError):
+ conn.query_tagging = "no"
+
+ def test_set_query_tag_normal_conn(self):
+ conn = TSC.ConnectionItem()
+ conn._connection_type = "postgres"
+ conn.query_tagging = True
+ self.assertEqual(conn.query_tagging, True)
+
+ def test_ignore_query_tag_for_hyper(self):
+ conn = TSC.ConnectionItem()
+ conn._connection_type = "hyper"
+ conn.query_tagging = True
+ self.assertEqual(conn.query_tagging, None)
+
+ def test_ignore_query_tag_for_teradata(self):
+ conn = TSC.ConnectionItem()
+ conn._connection_type = "teradata"
+ conn.query_tagging = True
+ self.assertEqual(conn.query_tagging, None)
+
+ def test_ignore_query_tag_for_snowflake(self):
+ conn = TSC.ConnectionItem()
+ conn._connection_type = "snowflake"
+ conn.query_tagging = True
+ self.assertEqual(conn.query_tagging, None)
diff --git a/test/test_custom_view.py b/test/test_custom_view.py
new file mode 100644
index 000000000..6e863a863
--- /dev/null
+++ b/test/test_custom_view.py
@@ -0,0 +1,320 @@
+from contextlib import ExitStack
+import io
+import os
+from pathlib import Path
+from tempfile import TemporaryDirectory
+import unittest
+
+import requests_mock
+
+import tableauserverclient as TSC
+from tableauserverclient.config import BYTES_PER_MB
+from tableauserverclient.datetime_helpers import format_datetime
+from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError
+
+TEST_ASSET_DIR = Path(__file__).parent / "assets"
+
+GET_XML = os.path.join(TEST_ASSET_DIR, "custom_view_get.xml")
+GET_XML_ID = os.path.join(TEST_ASSET_DIR, "custom_view_get_id.xml")
+POPULATE_PREVIEW_IMAGE = os.path.join(TEST_ASSET_DIR, "Sample View Image.png")
+CUSTOM_VIEW_UPDATE_XML = os.path.join(TEST_ASSET_DIR, "custom_view_update.xml")
+CUSTOM_VIEW_POPULATE_PDF = os.path.join(TEST_ASSET_DIR, "populate_pdf.pdf")
+CUSTOM_VIEW_POPULATE_CSV = os.path.join(TEST_ASSET_DIR, "populate_csv.csv")
+CUSTOM_VIEW_DOWNLOAD = TEST_ASSET_DIR / "custom_view_download.json"
+FILE_UPLOAD_INIT = TEST_ASSET_DIR / "fileupload_initialize.xml"
+FILE_UPLOAD_APPEND = TEST_ASSET_DIR / "fileupload_append.xml"
+
+
+class CustomViewTests(unittest.TestCase):
+ def setUp(self):
+ self.server = TSC.Server("http://test", False)
+ self.server.version = "3.21" # custom views only introduced in 3.19
+
+ # Fake sign in
+ self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67"
+ self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM"
+
+ self.baseurl = self.server.custom_views.baseurl
+
+ def test_get(self) -> None:
+ with open(GET_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+ print(response_xml)
+ with requests_mock.mock() as m:
+ m.get(self.baseurl, text=response_xml)
+ all_views, pagination_item = self.server.custom_views.get()
+
+ self.assertEqual(2, pagination_item.total_available)
+ self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", all_views[0].id)
+ self.assertEqual("ENDANGERED SAFARI", all_views[0].name)
+ self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", all_views[0].content_url)
+ self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", all_views[0].workbook.id)
+ self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_views[0].owner.id)
+ self.assertIsNone(all_views[0].created_at)
+ self.assertIsNone(all_views[0].updated_at)
+ self.assertFalse(all_views[0].shared)
+
+ self.assertEqual("fd252f73-593c-4c4e-8584-c032b8022adc", all_views[1].id)
+ self.assertEqual("Overview", all_views[1].name)
+ self.assertEqual("6d13b0ca-043d-4d42-8c9d-3f3313ea3a00", all_views[1].workbook.id)
+ self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_views[1].owner.id)
+ self.assertEqual("2002-05-30T09:00:00Z", format_datetime(all_views[1].created_at))
+ self.assertEqual("2002-06-05T08:00:59Z", format_datetime(all_views[1].updated_at))
+ self.assertTrue(all_views[1].shared)
+
+ def test_get_by_id(self) -> None:
+ with open(GET_XML_ID, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+ with requests_mock.mock() as m:
+ m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5", text=response_xml)
+ view: TSC.CustomViewItem = self.server.custom_views.get_by_id("d79634e1-6063-4ec9-95ff-50acbf609ff5")
+
+ self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", view.id)
+ self.assertEqual("ENDANGERED SAFARI", view.name)
+ self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", view.content_url)
+ if view.workbook:
+ self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", view.workbook.id)
+ if view.owner:
+ self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", view.owner.id)
+ if view.view:
+ self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", view.view.id)
+ self.assertEqual("2002-05-30T09:00:00Z", format_datetime(view.created_at))
+ self.assertEqual("2002-06-05T08:00:59Z", format_datetime(view.updated_at))
+
+ def test_get_by_id_missing_id(self) -> None:
+ self.assertRaises(TSC.MissingRequiredFieldError, self.server.custom_views.get_by_id, None)
+
+ def test_get_before_signin(self) -> None:
+ self.server._auth_token = None
+ self.assertRaises(TSC.NotSignedInError, self.server.custom_views.get)
+
+ def test_populate_image(self) -> None:
+ with open(POPULATE_PREVIEW_IMAGE, "rb") as f:
+ response = f.read()
+ with requests_mock.mock() as m:
+ m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image", content=response)
+ single_view = TSC.CustomViewItem()
+ single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5"
+ self.server.custom_views.populate_image(single_view)
+ self.assertEqual(response, single_view.image)
+
+ def test_populate_image_with_options(self) -> None:
+ with open(POPULATE_PREVIEW_IMAGE, "rb") as f:
+ response = f.read()
+ with requests_mock.mock() as m:
+ m.get(
+ self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?resolution=high&maxAge=10", content=response
+ )
+ single_view = TSC.CustomViewItem()
+ single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5"
+ req_option = TSC.ImageRequestOptions(imageresolution=TSC.ImageRequestOptions.Resolution.High, maxage=10)
+ self.server.custom_views.populate_image(single_view, req_option)
+ self.assertEqual(response, single_view.image)
+
+ def test_populate_image_missing_id(self) -> None:
+ single_view = TSC.CustomViewItem()
+ single_view._id = None
+ self.assertRaises(TSC.MissingRequiredFieldError, self.server.custom_views.populate_image, single_view)
+
+ def test_delete(self) -> None:
+ with requests_mock.mock() as m:
+ m.delete(self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42", status_code=204)
+ self.server.custom_views.delete("3cc6cd06-89ce-4fdc-b935-5294135d6d42")
+
+ def test_delete_missing_id(self) -> None:
+ self.assertRaises(ValueError, self.server.custom_views.delete, "")
+
+ def test_update(self) -> None:
+ with open(CUSTOM_VIEW_UPDATE_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+ with requests_mock.mock() as m:
+ m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml)
+ the_custom_view = TSC.CustomViewItem("1d0304cd-3796-429f-b815-7258370b9b74", name="Best test ever")
+ the_custom_view._id = "1f951daf-4061-451a-9df1-69a8062664f2"
+ the_custom_view.owner = TSC.UserItem()
+ the_custom_view.owner.id = "dd2239f6-ddf1-4107-981a-4cf94e415794"
+ the_custom_view = self.server.custom_views.update(the_custom_view)
+
+ self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", the_custom_view.id)
+ if the_custom_view.owner:
+ self.assertEqual("dd2239f6-ddf1-4107-981a-4cf94e415794", the_custom_view.owner.id)
+ self.assertEqual("Best test ever", the_custom_view.name)
+
+ def test_update_missing_id(self) -> None:
+ cv = TSC.CustomViewItem(name="test")
+ self.assertRaises(TSC.MissingRequiredFieldError, self.server.custom_views.update, cv)
+
+ def test_download(self) -> None:
+ cv = TSC.CustomViewItem(name="test")
+ cv._id = "1f951daf-4061-451a-9df1-69a8062664f2"
+ content = CUSTOM_VIEW_DOWNLOAD.read_bytes()
+ data = io.BytesIO()
+ with requests_mock.mock() as m:
+ m.get(f"{self.server.custom_views.expurl}/1f951daf-4061-451a-9df1-69a8062664f2/content", content=content)
+ self.server.custom_views.download(cv, data)
+
+ assert data.getvalue() == content
+
+ def test_publish_filepath(self) -> None:
+ cv = TSC.CustomViewItem(name="test")
+ cv._owner = TSC.UserItem()
+ cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794"
+ cv._workbook = TSC.WorkbookItem()
+ cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2"
+ with requests_mock.mock() as m:
+ m.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text())
+ view = self.server.custom_views.publish(cv, CUSTOM_VIEW_DOWNLOAD)
+
+ assert view is not None
+ assert isinstance(view, TSC.CustomViewItem)
+ assert view.id is not None
+ assert view.name is not None
+
+ def test_publish_file_str(self) -> None:
+ cv = TSC.CustomViewItem(name="test")
+ cv._owner = TSC.UserItem()
+ cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794"
+ cv._workbook = TSC.WorkbookItem()
+ cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2"
+ with requests_mock.mock() as m:
+ m.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text())
+ view = self.server.custom_views.publish(cv, str(CUSTOM_VIEW_DOWNLOAD))
+
+ assert view is not None
+ assert isinstance(view, TSC.CustomViewItem)
+ assert view.id is not None
+ assert view.name is not None
+
+ def test_publish_file_io(self) -> None:
+ cv = TSC.CustomViewItem(name="test")
+ cv._owner = TSC.UserItem()
+ cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794"
+ cv._workbook = TSC.WorkbookItem()
+ cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2"
+ data = io.BytesIO(CUSTOM_VIEW_DOWNLOAD.read_bytes())
+ with requests_mock.mock() as m:
+ m.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text())
+ view = self.server.custom_views.publish(cv, data)
+
+ assert view is not None
+ assert isinstance(view, TSC.CustomViewItem)
+ assert view.id is not None
+ assert view.name is not None
+
+ def test_publish_missing_owner_id(self) -> None:
+ cv = TSC.CustomViewItem(name="test")
+ cv._owner = TSC.UserItem()
+ cv._workbook = TSC.WorkbookItem()
+ cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2"
+ with requests_mock.mock() as m:
+ m.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text())
+ with self.assertRaises(ValueError):
+ self.server.custom_views.publish(cv, CUSTOM_VIEW_DOWNLOAD)
+
+ def test_publish_missing_wb_id(self) -> None:
+ cv = TSC.CustomViewItem(name="test")
+ cv._owner = TSC.UserItem()
+ cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794"
+ cv._workbook = TSC.WorkbookItem()
+ with requests_mock.mock() as m:
+ m.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text())
+ with self.assertRaises(ValueError):
+ self.server.custom_views.publish(cv, CUSTOM_VIEW_DOWNLOAD)
+
+ def test_large_publish(self):
+ cv = TSC.CustomViewItem(name="test")
+ cv._owner = TSC.UserItem()
+ cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794"
+ cv._workbook = TSC.WorkbookItem()
+ cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2"
+ with ExitStack() as stack:
+ temp_dir = stack.enter_context(TemporaryDirectory())
+ file_path = Path(temp_dir) / "test_file"
+ file_path.write_bytes(os.urandom(65 * BYTES_PER_MB))
+ mock = stack.enter_context(requests_mock.mock())
+ # Mock initializing upload
+ mock.post(self.server.fileuploads.baseurl, status_code=201, text=FILE_UPLOAD_INIT.read_text())
+ # Mock the upload
+ mock.put(
+ f"{self.server.fileuploads.baseurl}/7720:170fe6b1c1c7422dadff20f944d58a52-1:0",
+ text=FILE_UPLOAD_APPEND.read_text(),
+ )
+ # Mock the publish
+ mock.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text())
+
+ view = self.server.custom_views.publish(cv, file_path)
+
+ assert view is not None
+ assert isinstance(view, TSC.CustomViewItem)
+ assert view.id is not None
+ assert view.name is not None
+
+ def test_populate_pdf(self) -> None:
+ self.server.version = "3.23"
+ self.baseurl = self.server.custom_views.baseurl
+ with open(CUSTOM_VIEW_POPULATE_PDF, "rb") as f:
+ response = f.read()
+ with requests_mock.mock() as m:
+ m.get(
+ self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/pdf?type=letter&orientation=portrait&maxAge=5",
+ content=response,
+ )
+ custom_view = TSC.CustomViewItem()
+ custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5"
+
+ size = TSC.PDFRequestOptions.PageType.Letter
+ orientation = TSC.PDFRequestOptions.Orientation.Portrait
+ req_option = TSC.PDFRequestOptions(size, orientation, 5)
+
+ self.server.custom_views.populate_pdf(custom_view, req_option)
+ self.assertEqual(response, custom_view.pdf)
+
+ def test_populate_csv(self) -> None:
+ self.server.version = "3.23"
+ self.baseurl = self.server.custom_views.baseurl
+ with open(CUSTOM_VIEW_POPULATE_CSV, "rb") as f:
+ response = f.read()
+ with requests_mock.mock() as m:
+ m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/data?maxAge=1", content=response)
+ custom_view = TSC.CustomViewItem()
+ custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5"
+ request_option = TSC.CSVRequestOptions(maxage=1)
+ self.server.custom_views.populate_csv(custom_view, request_option)
+
+ csv_file = b"".join(custom_view.csv)
+ self.assertEqual(response, csv_file)
+
+ def test_populate_csv_default_maxage(self) -> None:
+ self.server.version = "3.23"
+ self.baseurl = self.server.custom_views.baseurl
+ with open(CUSTOM_VIEW_POPULATE_CSV, "rb") as f:
+ response = f.read()
+ with requests_mock.mock() as m:
+ m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/data", content=response)
+ custom_view = TSC.CustomViewItem()
+ custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5"
+ self.server.custom_views.populate_csv(custom_view)
+
+ csv_file = b"".join(custom_view.csv)
+ self.assertEqual(response, csv_file)
+
+ def test_pdf_height(self) -> None:
+ self.server.version = "3.23"
+ self.baseurl = self.server.custom_views.baseurl
+ with open(CUSTOM_VIEW_POPULATE_PDF, "rb") as f:
+ response = f.read()
+ with requests_mock.mock() as m:
+ m.get(
+ self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/pdf?vizHeight=1080&vizWidth=1920",
+ content=response,
+ )
+ custom_view = TSC.CustomViewItem()
+ custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5"
+
+ req_option = TSC.PDFRequestOptions(
+ viz_height=1080,
+ viz_width=1920,
+ )
+
+ self.server.custom_views.populate_pdf(custom_view, req_option)
+ self.assertEqual(response, custom_view.pdf)
diff --git a/test/test_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_data_freshness_policy.py b/test/test_data_freshness_policy.py
new file mode 100644
index 000000000..9591a6380
--- /dev/null
+++ b/test/test_data_freshness_policy.py
@@ -0,0 +1,189 @@
+import os
+import requests_mock
+import unittest
+
+import tableauserverclient as TSC
+
+TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets")
+
+UPDATE_DFP_ALWAYS_LIVE_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_data_freshness_policy.xml")
+UPDATE_DFP_SITE_DEFAULT_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_data_freshness_policy2.xml")
+UPDATE_DFP_FRESH_EVERY_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_data_freshness_policy3.xml")
+UPDATE_DFP_FRESH_AT_DAILY_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_data_freshness_policy4.xml")
+UPDATE_DFP_FRESH_AT_WEEKLY_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_data_freshness_policy5.xml")
+UPDATE_DFP_FRESH_AT_MONTHLY_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_data_freshness_policy6.xml")
+
+
+class WorkbookTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self.server = TSC.Server("http://test", False)
+
+ # Fake sign in
+ self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67"
+ self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM"
+
+ self.baseurl = self.server.workbooks.baseurl
+
+ def test_update_DFP_always_live(self) -> None:
+ with open(UPDATE_DFP_ALWAYS_LIVE_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+ with requests_mock.mock() as m:
+ m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml)
+ single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True)
+ single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2"
+ single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem(
+ TSC.DataFreshnessPolicyItem.Option.AlwaysLive
+ )
+ single_workbook = self.server.workbooks.update(single_workbook)
+
+ self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id)
+ self.assertEqual("AlwaysLive", single_workbook.data_freshness_policy.option)
+
+ def test_update_DFP_site_default(self) -> None:
+ with open(UPDATE_DFP_SITE_DEFAULT_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+ with requests_mock.mock() as m:
+ m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml)
+ single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True)
+ single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2"
+ single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem(
+ TSC.DataFreshnessPolicyItem.Option.SiteDefault
+ )
+ single_workbook = self.server.workbooks.update(single_workbook)
+
+ self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id)
+ self.assertEqual("SiteDefault", single_workbook.data_freshness_policy.option)
+
+ def test_update_DFP_fresh_every(self) -> None:
+ with open(UPDATE_DFP_FRESH_EVERY_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+ with requests_mock.mock() as m:
+ m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml)
+ single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True)
+ single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2"
+ single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem(
+ TSC.DataFreshnessPolicyItem.Option.FreshEvery
+ )
+ fresh_every_ten_hours = TSC.DataFreshnessPolicyItem.FreshEvery(
+ TSC.DataFreshnessPolicyItem.FreshEvery.Frequency.Hours, 10
+ )
+ single_workbook.data_freshness_policy.fresh_every_schedule = fresh_every_ten_hours
+ single_workbook = self.server.workbooks.update(single_workbook)
+
+ self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id)
+ self.assertEqual("FreshEvery", single_workbook.data_freshness_policy.option)
+ self.assertEqual("Hours", single_workbook.data_freshness_policy.fresh_every_schedule.frequency)
+ self.assertEqual(10, single_workbook.data_freshness_policy.fresh_every_schedule.value)
+
+ def test_update_DFP_fresh_every_missing_attributes(self) -> None:
+ with open(UPDATE_DFP_FRESH_EVERY_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+ with requests_mock.mock() as m:
+ m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml)
+ single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True)
+ single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2"
+ single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem(
+ TSC.DataFreshnessPolicyItem.Option.FreshEvery
+ )
+
+ self.assertRaises(ValueError, self.server.workbooks.update, single_workbook)
+
+ def test_update_DFP_fresh_at_day(self) -> None:
+ with open(UPDATE_DFP_FRESH_AT_DAILY_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+ with requests_mock.mock() as m:
+ m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml)
+ single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True)
+ single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2"
+ single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem(
+ TSC.DataFreshnessPolicyItem.Option.FreshAt
+ )
+ fresh_at_10pm_daily = TSC.DataFreshnessPolicyItem.FreshAt(
+ TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Day, "22:00:00", " Asia/Singapore"
+ )
+ single_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_10pm_daily
+ single_workbook = self.server.workbooks.update(single_workbook)
+
+ self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id)
+ self.assertEqual("FreshAt", single_workbook.data_freshness_policy.option)
+ self.assertEqual("Day", single_workbook.data_freshness_policy.fresh_at_schedule.frequency)
+ self.assertEqual("22:00:00", single_workbook.data_freshness_policy.fresh_at_schedule.time)
+ self.assertEqual("Asia/Singapore", single_workbook.data_freshness_policy.fresh_at_schedule.timezone)
+
+ def test_update_DFP_fresh_at_week(self) -> None:
+ with open(UPDATE_DFP_FRESH_AT_WEEKLY_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+ with requests_mock.mock() as m:
+ m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml)
+ single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True)
+ single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2"
+ single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem(
+ TSC.DataFreshnessPolicyItem.Option.FreshAt
+ )
+ fresh_at_10am_mon_wed = TSC.DataFreshnessPolicyItem.FreshAt(
+ TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Week,
+ "10:00:00",
+ "America/Los_Angeles",
+ ["Monday", "Wednesday"],
+ )
+ single_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_10am_mon_wed
+ single_workbook = self.server.workbooks.update(single_workbook)
+
+ self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id)
+ self.assertEqual("FreshAt", single_workbook.data_freshness_policy.option)
+ self.assertEqual("Week", single_workbook.data_freshness_policy.fresh_at_schedule.frequency)
+ self.assertEqual("10:00:00", single_workbook.data_freshness_policy.fresh_at_schedule.time)
+ self.assertEqual("Wednesday", single_workbook.data_freshness_policy.fresh_at_schedule.interval_item[0])
+ self.assertEqual("Monday", single_workbook.data_freshness_policy.fresh_at_schedule.interval_item[1])
+
+ def test_update_DFP_fresh_at_month(self) -> None:
+ with open(UPDATE_DFP_FRESH_AT_MONTHLY_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+ with requests_mock.mock() as m:
+ m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml)
+ single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True)
+ single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2"
+ single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem(
+ TSC.DataFreshnessPolicyItem.Option.FreshAt
+ )
+ fresh_at_00am_lastDayOfMonth = TSC.DataFreshnessPolicyItem.FreshAt(
+ TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Month, "00:00:00", "America/Los_Angeles", ["LastDay"]
+ )
+ single_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_00am_lastDayOfMonth
+ single_workbook = self.server.workbooks.update(single_workbook)
+
+ self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id)
+ self.assertEqual("FreshAt", single_workbook.data_freshness_policy.option)
+ self.assertEqual("Month", single_workbook.data_freshness_policy.fresh_at_schedule.frequency)
+ self.assertEqual("00:00:00", single_workbook.data_freshness_policy.fresh_at_schedule.time)
+ self.assertEqual("LastDay", single_workbook.data_freshness_policy.fresh_at_schedule.interval_item[0])
+
+ def test_update_DFP_fresh_at_missing_params(self) -> None:
+ with open(UPDATE_DFP_FRESH_AT_DAILY_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+ with requests_mock.mock() as m:
+ m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml)
+ single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True)
+ single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2"
+ single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem(
+ TSC.DataFreshnessPolicyItem.Option.FreshAt
+ )
+
+ self.assertRaises(ValueError, self.server.workbooks.update, single_workbook)
+
+ def test_update_DFP_fresh_at_missing_interval(self) -> None:
+ with open(UPDATE_DFP_FRESH_AT_DAILY_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+ with requests_mock.mock() as m:
+ m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml)
+ single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True)
+ single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2"
+ single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem(
+ TSC.DataFreshnessPolicyItem.Option.FreshAt
+ )
+ fresh_at_month_no_interval = TSC.DataFreshnessPolicyItem.FreshAt(
+ TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Month, "00:00:00", "America/Los_Angeles"
+ )
+ single_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_month_no_interval
+
+ self.assertRaises(ValueError, self.server.workbooks.update, single_workbook)
diff --git a/test/test_dataalert.py b/test/test_dataalert.py
index 7822d3000..6f6f1683c 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 + f"/{alert_id}/users/{user_id}", status_code=204)
self.server.data_alerts.delete_user_from_alert(alert_id, user_id)
diff --git a/test/test_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..a604ba8b0 100644
--- a/test/test_datasource.py
+++ b/test/test_datasource.py
@@ -1,85 +1,92 @@
-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 typing import Optional
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 import ConnectionItem
+from tableauserverclient.datetime_helpers import format_datetime, parse_datetime
from tableauserverclient.server.endpoint.exceptions import InternalServerError
+from tableauserverclient.server.endpoint.fileuploads_endpoint import Fileuploads
from tableauserverclient.server.request_factory import RequestFactory
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"
+GET_XML_ALL_FIELDS = "datasource_get_all_fields.xml"
+POPULATE_CONNECTIONS_XML = "datasource_populate_connections.xml"
+POPULATE_PERMISSIONS_XML = "datasource_populate_permissions.xml"
+PUBLISH_XML = "datasource_publish.xml"
+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(4096, all_datasources[0].size)
+ self.assertEqual("2016-08-11T21:22:40Z", format_datetime(all_datasources[0].created_at))
+ self.assertEqual("2016-08-11T21:34:17Z", format_datetime(all_datasources[0].updated_at))
+ self.assertEqual("default", all_datasources[0].project_name)
+ 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(10240, all_datasources[1].size)
+ self.assertEqual("2016-08-04T21:31:55Z", format_datetime(all_datasources[1].created_at))
+ self.assertEqual("2016-08-04T21:31:55Z", format_datetime(all_datasources[1].updated_at))
+ self.assertEqual("default", all_datasources[1].project_name)
+ self.assertEqual("Sample datasource", all_datasources[1].name)
+ self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", all_datasources[1].project_id)
+ self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_datasources[1].owner_id)
+ self.assertEqual({"world", "indicators", "sample"}, all_datasources[1].tags)
+ self.assertEqual("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 +95,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({"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 +134,639 @@ 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.delete(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags/b", status_code=204)
+ m.delete(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags/d", status_code=204)
+ m.put(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags", text=add_tags_xml)
+ m.put(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", text=update_xml)
+ single_datasource = TSC.DatasourceItem("1d0304cd-3796-429f-b815-7258370b9b74")
+ single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb"
+ 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)
- connections = single_datasource.connections
+ self.assertEqual("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", single_datasource.id)
+ connections: Optional[list[ConnectionItem]] = single_datasource.connections
- self.assertTrue(connections)
+ self.assertIsNotNone(connections)
+ assert connections is not None
ds1, ds2 = connections
- self.assertEqual('be786ae0-d2bf-4a4b-9b34-e2de8d2d4488', ds1.id)
- self.assertEqual('textscan', ds1.connection_type)
- self.assertEqual('forty-two.net', ds1.server_address)
- self.assertEqual('duo', ds1.username)
+ self.assertEqual("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(ds1.datasource_id, single_datasource.id)
+ self.assertEqual(single_datasource.name, ds1.datasource_name)
+ self.assertEqual("970e24bc-e200-4841-a3e9-66e7d122d77e", ds2.id)
+ self.assertEqual("sqlserver", ds2.connection_type)
+ self.assertEqual("database.com", ds2.server_address)
+ self.assertEqual("heero", ds2.username)
self.assertEqual(False, ds2.embed_password)
+ self.assertEqual(ds2.datasource_id, single_datasource.id)
+ self.assertEqual(single_datasource.name, ds2.datasource_name)
- def test_update_connection(self):
+ 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("be786ae0-d2bf-4a4b-9b34-e2de8d2d4488")
+ single_datasource.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794"
+ single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb"
self.server.datasources.populate_connections(single_datasource)
- 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_datasource_refresh_request_empty(self) -> None:
+ self.server.version = "2.8"
+ self.baseurl = self.server.datasources.baseurl
+ item = TSC.DatasourceItem("")
+ item._id = "1234"
+ text = read_xml_asset(REFRESH_XML)
+
+ def match_request_body(request):
+ try:
+ root = fromstring(request.body)
+ assert root.tag == "tsRequest"
+ assert len(root) == 0
+ return True
+ except Exception:
+ return False
+
+ with requests_mock.mock() as m:
+ m.post(f"{self.baseurl}/1234/refresh", text=text, additional_matcher=match_request_body)
+
+ def test_update_hyper_data_datasource_object(self) -> None:
"""Calling `update_hyper_data` with a `DatasourceItem` should update that datasource"""
self.server.version = "3.13"
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_object(self) -> None:
+ with BytesIO() as file_object:
+ with requests_mock.mock() as m:
+ m.get(
+ self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content",
+ headers={"Content-Disposition": 'name="tableau_datasource"; filename="Sample datasource.tds"'},
+ )
+ file_path = self.server.datasources.download(
+ "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", filepath=file_object
+ )
+ self.assertTrue(isinstance(file_path, BytesIO))
+
+ def test_download_sanitizes_name(self) -> None:
filename = "Name,With,Commas.tds"
- disposition = 'name="tableau_workbook"; filename="{}"'.format(filename)
+ disposition = f'name="tableau_workbook"; filename="{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)
-
- def test_delete_extracts(self):
+ # http://test/api/2.4/sites/dad65087-b08b-4603-af4e-2887b8aafc67/datasources?datasourceType=tds
+ 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) -> 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(f"{self.baseurl}/{datasource.id}/revisions", 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(f"{self.baseurl}/{datasource.id}/revisions/3")
+ 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))
+
+ def test_bad_download_response(self) -> None:
+ with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td:
+ m.get(
+ self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content",
+ headers={
+ "Content-Disposition": '''name="tableau_datasource"; filename*=UTF-8''"Sample datasource.tds"'''
+ },
+ )
+ file_path = self.server.datasources.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", td)
+ self.assertTrue(os.path.exists(file_path))
+
+ def test_get_datasource_all_fields(self) -> None:
+ ro = TSC.RequestOptions()
+ ro.all_fields = True
+ with requests_mock.mock() as m:
+ m.get(f"{self.baseurl}?fields=_all_", text=read_xml_asset(GET_XML_ALL_FIELDS))
+ datasources, _ = self.server.datasources.get(req_options=ro)
+
+ assert datasources[0].connected_workbooks_count == 0
+ assert datasources[0].content_url == "SuperstoreDatasource"
+ assert datasources[0].created_at == parse_datetime("2024-02-14T04:42:13Z")
+ assert not datasources[0].encrypt_extracts
+ assert datasources[0].favorites_total == 0
+ assert not datasources[0].has_alert
+ assert not datasources[0].has_extracts
+ assert datasources[0].id == "a71cdd15-3a23-4ec1-b3ce-9956f5e00bb7"
+ assert not datasources[0].certified
+ assert datasources[0].is_published
+ assert datasources[0].name == "Superstore Datasource"
+ assert datasources[0].size == 1
+ assert datasources[0].datasource_type == "excel-direct"
+ assert datasources[0].updated_at == parse_datetime("2024-02-14T04:42:14Z")
+ assert not datasources[0].use_remote_query_agent
+ assert datasources[0].server_name == "localhost"
+ assert datasources[0].webpage_url == "https://10ax.online.tableau.com/#/site/example/datasources/3566752"
+ assert isinstance(datasources[0].project, TSC.ProjectItem)
+ assert datasources[0].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a"
+ assert datasources[0].project.name == "Samples"
+ assert datasources[0].project.description == "This project includes automatically uploaded samples."
+ assert datasources[0].owner.email == "bob@example.com"
+ assert isinstance(datasources[0].owner, TSC.UserItem)
+ assert datasources[0].owner.fullname == "Bob Smith"
+ assert datasources[0].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9"
+ assert datasources[0].owner.last_login == parse_datetime("2025-02-04T06:39:20Z")
+ assert datasources[0].owner.name == "bob@example.com"
+ assert datasources[0].owner.site_role == "SiteAdministratorCreator"
diff --git a/test/test_datasource_model.py b/test/test_datasource_model.py
index 600587801..655284194 100644
--- a/test/test_datasource_model.py
+++ b/test/test_datasource_model.py
@@ -1,11 +1,18 @@
-import datetime
import unittest
import tableauserverclient as TSC
class DatasourceModelTests(unittest.TestCase):
- def test_invalid_project_id(self):
- self.assertRaises(ValueError, TSC.DatasourceItem, None)
+ def test_nullable_project_id(self):
+ datasource = TSC.DatasourceItem(name="10")
+ self.assertEqual(datasource.project_id, None)
+
+ def test_require_boolean_flag_bridge_fail(self):
datasource = TSC.DatasourceItem("10")
with self.assertRaises(ValueError):
- datasource.project_id = None
+ datasource.use_remote_query_agent = "yes"
+
+ def test_require_boolean_flag_bridge_ok(self):
+ datasource = TSC.DatasourceItem("10")
+ datasource.use_remote_query_agent = True
+ self.assertEqual(datasource.use_remote_query_agent, True)
diff --git a/test/test_dqw.py b/test/test_dqw.py
new file mode 100644
index 000000000..6d1219f66
--- /dev/null
+++ b/test/test_dqw.py
@@ -0,0 +1,11 @@
+import unittest
+import tableauserverclient as TSC
+
+
+class DQWTests(unittest.TestCase):
+ def test_existence(self):
+ dqw: TSC.DQWItem = TSC.DQWItem()
+ dqw.message = "message"
+ dqw.warning_type = TSC.DQWItem.WarningType.STALE
+ dqw.active = True
+ dqw.severe = True
diff --git a/test/test_endpoint.py b/test/test_endpoint.py
new file mode 100644
index 000000000..ff1ef0f72
--- /dev/null
+++ b/test/test_endpoint.py
@@ -0,0 +1,83 @@
+from pathlib import Path
+import pytest
+import requests
+import unittest
+
+import tableauserverclient as TSC
+
+import requests_mock
+
+ASSETS = Path(__file__).parent / "assets"
+
+
+class TestEndpoint(unittest.TestCase):
+ def setUp(self) -> None:
+ self.server = TSC.Server("http://test/", use_server_version=False)
+
+ # Fake signin
+ self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67"
+ self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM"
+ return super().setUp()
+
+ def test_fallback_request_logic(self) -> None:
+ url = "http://test/"
+ endpoint = TSC.server.Endpoint(self.server)
+ with requests_mock.mock() as m:
+ m.get(url)
+ response = endpoint.get_request(url=url)
+ self.assertIsNotNone(response)
+
+ def test_user_friendly_request_returns(self) -> None:
+ url = "http://test/"
+ endpoint = TSC.server.Endpoint(self.server)
+ with requests_mock.mock() as m:
+ m.get(url)
+ response = endpoint.send_request_while_show_progress_threaded(
+ endpoint.parent_srv.session.get, url=url, request_timeout=2
+ )
+ self.assertIsNotNone(response)
+
+ def test_blocking_request_raises_request_error(self) -> None:
+ with pytest.raises(requests.exceptions.ConnectionError):
+ url = "http://test/"
+ endpoint = TSC.server.Endpoint(self.server)
+ response = endpoint._blocking_request(endpoint.parent_srv.session.get, url=url)
+ self.assertIsNotNone(response)
+
+ def test_get_request_stream(self) -> None:
+ url = "http://test/"
+ endpoint = TSC.server.Endpoint(self.server)
+ with requests_mock.mock() as m:
+ m.get(url, headers={"Content-Type": "application/octet-stream"})
+ response = endpoint.get_request(url, parameters={"stream": True})
+
+ self.assertFalse(response._content_consumed)
+
+ def test_binary_log_truncated(self):
+ class FakeResponse:
+ headers = {"Content-Type": "application/octet-stream"}
+ content = b"\x1337" * 1000
+ status_code = 200
+
+ endpoint = TSC.server.Endpoint(self.server)
+ server_response = FakeResponse()
+ log = endpoint.log_response_safely(server_response)
+ self.assertTrue(log.find("[Truncated File Contents]") > 0, log)
+
+ def test_set_user_agent_from_options_headers(self):
+ params = {"User-Agent": "1", "headers": {"User-Agent": "2"}}
+ result = TSC.server.Endpoint.set_user_agent(params)
+ # it should use the value under 'headers' if more than one is given
+ print(result)
+ print(result["headers"]["User-Agent"])
+ self.assertTrue(result["headers"]["User-Agent"] == "2")
+
+ def test_set_user_agent_from_options(self):
+ params = {"headers": {"User-Agent": "2"}}
+ result = TSC.server.Endpoint.set_user_agent(params)
+ self.assertTrue(result["headers"]["User-Agent"] == "2")
+
+ def test_set_user_agent_when_blank(self):
+ params = {"headers": {}}
+ result = TSC.server.Endpoint.set_user_agent(params)
+ self.assertTrue(result["headers"]["User-Agent"].startswith("Tableau Server Client"))
diff --git a/test/test_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..87332d70f 100644
--- a/test/test_favorites.py
+++ b/test/test_favorites.py
@@ -1,129 +1,119 @@
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(f"{self.baseurl}/{self.user.id}", text=response_xml)
self.server.favorites.get(self.user)
self.assertIsNotNone(self.user._favorites)
- self.assertEqual(len(self.user.favorites['workbooks']), 1)
- 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]
+ print("favorited: ")
+ print(workbook)
+ 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(f"{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(f"{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(f"{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(f"{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(f"{self.baseurl}/{self.user.id}/workbooks/{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(f"{self.baseurl}/{self.user.id}/views/{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(f"{self.baseurl}/{self.user.id}/datasources/{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(f"{baseurl}/{self.user.id}/projects/{project.id}")
self.server.favorites.delete_favorite_project(self.user, project)
diff --git a/test/test_filesys_helpers.py b/test/test_filesys_helpers.py
index 82fce8476..0f3234d5d 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,9 +9,7 @@
class FilesysTests(unittest.TestCase):
-
def test_get_file_size_returns_correct_size(self):
-
target_size = 1000 # bytes
with BytesIO() as f:
@@ -22,82 +20,76 @@ def test_get_file_size_returns_correct_size(self):
self.assertEqual(file_size, target_size)
def test_get_file_size_returns_zero_for_empty_file(self):
-
with BytesIO() as f:
file_size = get_file_object_size(f)
self.assertEqual(file_size, 0)
def test_get_file_size_coincides_with_built_in_method(self):
-
- asset_path = asset('SampleWB.twbx')
+ 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)
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(b"This is a zip file")
+ 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):
-
# Create a dummy png file
with BytesIO() as file_object:
png_signature = bytes.fromhex("89504E470D0A1A0A")
diff --git a/test/test_fileuploads.py b/test/test_fileuploads.py
index 51662e4a2..9567bc3ad 100644
--- a/test/test_fileuploads.py
+++ b/test/test_fileuploads.py
@@ -1,63 +1,89 @@
+import contextlib
+import io
import os
-import requests_mock
import unittest
-from ._utils import asset
+import requests_mock
+
+from tableauserverclient.config import BYTES_PER_MB, config
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')
+
+@contextlib.contextmanager
+def set_env(**environ):
+ old_environ = dict(os.environ)
+ os.environ.update(environ)
+ try:
+ yield
+ finally:
+ os.environ.clear()
+ os.environ.update(old_environ)
class FileuploadsTests(unittest.TestCase):
def setUp(self):
- self.server = Server('http://test')
+ 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 = f"{self.server.baseurl}/sites/{self.server.site_id}/fileUploads"
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(f"{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(f"{self.baseurl}/{upload_id}", text=append_response_xml)
actual = self.server.fileuploads.upload(file_content)
self.assertEqual(upload_id, actual)
+
+ def test_upload_chunks_config(self):
+ data = io.BytesIO()
+ data.write(b"1" * (config.CHUNK_SIZE_MB * BYTES_PER_MB + 1))
+ data.seek(0)
+ with set_env(TSC_CHUNK_SIZE_MB="1"):
+ chunker = self.server.fileuploads._read_chunks(data)
+ chunk = next(chunker)
+ assert len(chunk) == config.CHUNK_SIZE_MB * BYTES_PER_MB
+ data.seek(0)
+ assert len(chunk) < len(data.read())
diff --git a/test/test_filter.py b/test/test_filter.py
new file mode 100644
index 000000000..e2121307f
--- /dev/null
+++ b/test/test_filter.py
@@ -0,0 +1,22 @@
+import os
+import unittest
+
+import tableauserverclient as TSC
+
+
+class FilterTests(unittest.TestCase):
+ def setUp(self):
+ pass
+
+ def test_filter_equal(self):
+ filter = TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, "Superstore")
+
+ self.assertEqual(str(filter), "name:eq:Superstore")
+
+ def test_filter_in(self):
+ # create a IN filter condition with project names that
+ # contain spaces and "special" characters
+ projects_to_find = ["default", "Salesforce Sales Projeśt"]
+ filter = TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.In, projects_to_find)
+
+ self.assertEqual(str(filter), "name:in:[default,Salesforce Sales Projeśt]")
diff --git a/test/test_flow.py b/test/test_flow.py
index 545623d03..d458bc77b 100644
--- a/test/test_flow.py
+++ b/test/test_flow.py
@@ -1,135 +1,225 @@
-import unittest
import os
import requests_mock
-import xml.etree.ElementTree as ET
+import tempfile
+import unittest
+
+from io import BytesIO
+
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
+
+TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets")
-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 = os.path.join(TEST_ASSET_DIR, "flow_get.xml")
+POPULATE_CONNECTIONS_XML = os.path.join(TEST_ASSET_DIR, "flow_populate_connections.xml")
+POPULATE_PERMISSIONS_XML = os.path.join(TEST_ASSET_DIR, "flow_populate_permissions.xml")
+PUBLISH_XML = os.path.join(TEST_ASSET_DIR, "flow_publish.xml")
+UPDATE_XML = os.path.join(TEST_ASSET_DIR, "flow_update.xml")
+REFRESH_XML = os.path.join(TEST_ASSET_DIR, "flow_refresh.xml")
class FlowTests(unittest.TestCase):
- 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_download(self) -> None:
+ with requests_mock.mock() as m:
+ m.get(
+ self.baseurl + "/587daa37-b84d-4400-a9a2-aa90e0be7837/content",
+ headers={"Content-Disposition": 'name="tableau_flow"; filename="FlowOne.tfl"'},
+ )
+ file_path = self.server.flows.download("587daa37-b84d-4400-a9a2-aa90e0be7837")
+ self.assertTrue(os.path.exists(file_path))
+ os.remove(file_path)
+
+ def test_download_object(self) -> None:
+ with BytesIO() as file_object:
+ with requests_mock.mock() as m:
+ m.get(
+ self.baseurl + "/587daa37-b84d-4400-a9a2-aa90e0be7837/content",
+ headers={"Content-Disposition": 'name="tableau_flow"; filename="FlowOne.tfl"'},
+ )
+ file_path = self.server.flows.download("587daa37-b84d-4400-a9a2-aa90e0be7837", filepath=file_object)
+ self.assertTrue(isinstance(file_path, BytesIO))
+
+ def test_get(self) -> None:
response_xml = read_xml_asset(GET_XML)
with requests_mock.mock() as m:
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,
+ },
+ )
+
+ self.assertEqual(permissions[1].grantee.tag_name, "groupSet")
+ self.assertEqual(permissions[1].grantee.id, "7ea95a1b-6872-44d6-a969-68598a7df4a0")
+ self.assertDictEqual(
+ permissions[1].capabilities,
+ {
+ TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow,
+ },
+ )
+
+ def test_publish(self) -> None:
+ with open(PUBLISH_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+ with requests_mock.mock() as m:
+ m.post(self.baseurl, text=response_xml)
+
+ new_flow = TSC.FlowItem(name="SampleFlow", project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760")
+
+ sample_flow = os.path.join(TEST_ASSET_DIR, "SampleFlow.tfl")
+ publish_mode = self.server.PublishMode.CreateNew
+
+ new_flow = self.server.flows.publish(new_flow, sample_flow, publish_mode)
+
+ self.assertEqual("2457c468-1b24-461a-8f95-a461b3209d32", new_flow.id)
+ self.assertEqual("SampleFlow", new_flow.name)
+ self.assertEqual("2023-01-13T09:50:55Z", format_datetime(new_flow.created_at))
+ self.assertEqual("2023-01-13T09:50:55Z", format_datetime(new_flow.updated_at))
+ self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", new_flow.project_id)
+ self.assertEqual("default", new_flow.project_name)
+ self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", new_flow.owner_id)
+
+ def test_publish_file_object(self) -> None:
+ with open(PUBLISH_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+ with requests_mock.mock() as m:
+ m.post(self.baseurl, text=response_xml)
+
+ new_flow = TSC.FlowItem(name="SampleFlow", project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760")
+
+ sample_flow = os.path.join(TEST_ASSET_DIR, "SampleFlow.tfl")
+ publish_mode = self.server.PublishMode.CreateNew
+
+ with open(sample_flow, "rb") as fp:
+ publish_mode = self.server.PublishMode.CreateNew
+
+ new_flow = self.server.flows.publish(new_flow, fp, publish_mode)
+
+ self.assertEqual("2457c468-1b24-461a-8f95-a461b3209d32", new_flow.id)
+ self.assertEqual("SampleFlow", new_flow.name)
+ self.assertEqual("2023-01-13T09:50:55Z", format_datetime(new_flow.created_at))
+ self.assertEqual("2023-01-13T09:50:55Z", format_datetime(new_flow.updated_at))
+ self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", new_flow.project_id)
+ self.assertEqual("default", new_flow.project_name)
+ self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", new_flow.owner_id)
def test_refresh(self):
- with open(asset(REFRESH_XML), 'rb') as f:
- response_xml = f.read().decode('utf-8')
+ 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")
+
+ def test_bad_download_response(self) -> None:
+ with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td:
+ m.get(
+ self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content",
+ headers={"Content-Disposition": '''name="tableau_flow"; filename*=UTF-8''"Sample flow.tfl"'''},
+ )
+ file_path = self.server.flows.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", td)
+ self.assertTrue(os.path.exists(file_path))
diff --git a/test/test_flowruns.py b/test/test_flowruns.py
index d2e72f31a..8af2540dc 100644
--- a/test/test_flowruns.py
+++ b/test/test_flowruns.py
@@ -1,104 +1,111 @@
+import sys
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
+from ._utils import read_xml_asset, mocked_time, server_response_error_factory
-GET_XML = 'flow_runs_get.xml'
-GET_BY_ID_XML = 'flow_runs_get_by_id.xml'
-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):
+ all_flow_runs = self.server.flow_runs.get()
+
+ 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(f"{self.baseurl}/{flow_run_id}", text=response_xml)
flow_run = self.server.flow_runs.wait_for_job(flow_run_id)
self.assertEqual(flow_run_id, flow_run.id)
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(f"{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(f"{self.baseurl}/{flow_run_id}", text=response_xml)
with self.assertRaises(TimeoutError):
self.server.flow_runs.wait_for_job(flow_run_id, timeout=30)
+
+ def test_queryset(self) -> None:
+ response_xml = read_xml_asset(GET_XML)
+ error_response = server_response_error_factory(
+ "400006", "Bad Request", "0xB4EAB088 : The start index '9900' is greater than or equal to the total count.)"
+ )
+ with requests_mock.mock() as m:
+ m.get(f"{self.baseurl}?pageNumber=1", text=response_xml)
+ m.get(f"{self.baseurl}?pageNumber=2", text=error_response)
+ queryset = self.server.flow_runs.all()
+ assert len(queryset) == sys.maxsize
diff --git a/test/test_flowtask.py b/test/test_flowtask.py
new file mode 100644
index 000000000..2d9f7c7bd
--- /dev/null
+++ b/test/test_flowtask.py
@@ -0,0 +1,47 @@
+import os
+import unittest
+from datetime import time
+from pathlib import Path
+
+import requests_mock
+
+import tableauserverclient as TSC
+from tableauserverclient.datetime_helpers import parse_datetime
+from tableauserverclient.models.task_item import TaskItem
+
+TEST_ASSET_DIR = Path(__file__).parent / "assets"
+GET_XML_CREATE_FLOW_TASK_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_create_flow_task.xml")
+
+
+class TaskTests(unittest.TestCase):
+ def setUp(self):
+ self.server = TSC.Server("http://test", False)
+ self.server.version = "3.22"
+
+ # Fake Signin
+ self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67"
+ self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM"
+
+ self.baseurl = self.server.flow_tasks.baseurl
+
+ def test_create_flow_task(self):
+ monthly_interval = TSC.MonthlyInterval(start_time=time(23, 30), interval_value=15)
+ monthly_schedule = TSC.ScheduleItem(
+ "Monthly Schedule",
+ 50,
+ TSC.ScheduleItem.Type.Flow,
+ TSC.ScheduleItem.ExecutionOrder.Parallel,
+ monthly_interval,
+ )
+ target_item = TSC.Target("flow_id", "flow")
+
+ task = TaskItem(None, "RunFlow", None, schedule_item=monthly_schedule, target=target_item)
+
+ with open(GET_XML_CREATE_FLOW_TASK_RESPONSE, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+ with requests_mock.mock() as m:
+ m.post(f"{self.baseurl}", text=response_xml)
+ create_response_content = self.server.flow_tasks.create(task).decode("utf-8")
+
+ self.assertTrue("schedule_id" in create_response_content)
+ self.assertTrue("flow_id" in create_response_content)
diff --git a/test/test_group.py b/test/test_group.py
index 082a63ba3..b3de07963 100644
--- a/test/test_group.py
+++ b/test/test_group.py
@@ -1,236 +1,335 @@
-# encoding=utf-8
+from pathlib import Path
import unittest
import os
import requests_mock
import tableauserverclient as TSC
from tableauserverclient.datetime_helpers import format_datetime
-TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets')
+TEST_ASSET_DIR = Path(__file__).absolute().parent / "assets"
-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')
+# TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets")
+
+GET_XML = os.path.join(TEST_ASSET_DIR, "group_get.xml")
+GET_XML_ALL_FIELDS = TEST_ASSET_DIR / "group_get_all_fields.xml"
+POPULATE_USERS = os.path.join(TEST_ASSET_DIR, "group_populate_users.xml")
+POPULATE_USERS_EMPTY = os.path.join(TEST_ASSET_DIR, "group_populate_users_empty.xml")
+ADD_USER = os.path.join(TEST_ASSET_DIR, "group_add_user.xml")
+ADD_USERS = TEST_ASSET_DIR / "group_add_users.xml"
+ADD_USER_POPULATE = os.path.join(TEST_ASSET_DIR, "group_users_added.xml")
+CREATE_GROUP = os.path.join(TEST_ASSET_DIR, "group_create.xml")
+CREATE_GROUP_AD = os.path.join(TEST_ASSET_DIR, "group_create_ad.xml")
+CREATE_GROUP_ASYNC = os.path.join(TEST_ASSET_DIR, "group_create_async.xml")
+UPDATE_XML = os.path.join(TEST_ASSET_DIR, "group_update.xml")
+UPDATE_ASYNC_XML = TEST_ASSET_DIR / "group_update_async.xml"
class GroupTests(unittest.TestCase):
- 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)
+ self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", user.id)
+ self.assertEqual("testuser", user.name)
+ self.assertEqual("ServerAdministrator", user.site_role)
+
+ def test_add_users(self) -> None:
+ self.server.version = "3.21"
+ self.baseurl = self.server.groups.baseurl
+
+ def make_user(id: str, name: str, siteRole: str) -> TSC.UserItem:
+ user = TSC.UserItem(name, siteRole)
+ user._id = id
+ return user
+
+ users = [
+ make_user(id="5de011f8-4aa9-4d5b-b991-f464c8dd6bb7", name="Alice", siteRole="ServerAdministrator"),
+ make_user(id="5de011f8-3aa9-4d5b-b991-f467c8dd6bb8", name="Bob", siteRole="Explorer"),
+ make_user(id="5de011f8-2aa9-4d5b-b991-f466c8dd6bb8", name="Charlie", siteRole="Viewer"),
+ ]
+ group = TSC.GroupItem("test")
+ group._id = "e7833b48-c6f7-47b5-a2a7-36e7dd232758"
- 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')
+ with requests_mock.mock() as m:
+ m.post(f"{self.baseurl}/{group.id}/users", text=ADD_USERS.read_text())
+ resp_users = self.server.groups.add_users(group, users)
+
+ for user, resp_user in zip(users, resp_users):
+ with self.subTest(user=user, resp_user=resp_user):
+ assert user.id == resp_user.id
+ assert user.name == resp_user.name
+ assert user.site_role == resp_user.site_role
+
+ def test_remove_users(self) -> None:
+ self.server.version = "3.21"
+ self.baseurl = self.server.groups.baseurl
+
+ def make_user(id: str, name: str, siteRole: str) -> TSC.UserItem:
+ user = TSC.UserItem(name, siteRole)
+ user._id = id
+ return user
+
+ users = [
+ make_user(id="5de011f8-4aa9-4d5b-b991-f464c8dd6bb7", name="Alice", siteRole="ServerAdministrator"),
+ make_user(id="5de011f8-3aa9-4d5b-b991-f467c8dd6bb8", name="Bob", siteRole="Explorer"),
+ make_user(id="5de011f8-2aa9-4d5b-b991-f466c8dd6bb8", name="Charlie", siteRole="Viewer"),
+ ]
+ group = TSC.GroupItem("test")
+ group._id = "e7833b48-c6f7-47b5-a2a7-36e7dd232758"
+
+ with requests_mock.mock() as m:
+ m.put(f"{self.baseurl}/{group.id}/users/remove")
+ self.server.groups.remove_users(group, users)
+
+ def test_add_user_before_populating(self) -> None:
+ with open(GET_XML, "rb") as f:
+ get_xml_response = f.read().decode("utf-8")
+ 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(
+ self.baseurl + "/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(
+ self.baseurl + "/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'
group.domain_name = "local"
self.assertRaises(ValueError, self.server.groups.update, group, as_job=True)
+
+ def test_update_ad_async(self) -> None:
+ group = TSC.GroupItem("myGroup", "example.com")
+ group._id = "ef8b19c0-43b6-11e6-af50-63f5805dbe3c"
+ group.minimum_site_role = TSC.UserItem.Roles.Viewer
+
+ with requests_mock.mock() as m:
+ m.put(f"{self.baseurl}/{group.id}?asJob=True", text=UPDATE_ASYNC_XML.read_bytes().decode("utf8"))
+ job = self.server.groups.update(group, as_job=True)
+
+ self.assertEqual(job.id, "c2566efc-0767-4f15-89cb-56acb4349c1b")
+ self.assertEqual(job.mode, "Asynchronous")
+ self.assertEqual(job.type, "GroupSync")
+
+ def test_get_all_fields(self) -> None:
+ ro = TSC.RequestOptions()
+ ro.all_fields = True
+ self.server.version = "3.21"
+ self.baseurl = self.server.groups.baseurl
+ with requests_mock.mock() as m:
+ m.get(f"{self.baseurl}?fields=_all_", text=GET_XML_ALL_FIELDS.read_text())
+ groups, pages = self.server.groups.get(req_options=ro)
+
+ assert pages.total_available == 3
+ assert len(groups) == 3
+ assert groups[0].id == "28c5b855-16df-482f-ad0b-428c1df58859"
+ assert groups[0].name == "All Users"
+ assert groups[0].user_count == 2
+ assert groups[0].domain_name == "local"
+ assert groups[1].id == "ace1ee2d-e7dd-4d7a-9504-a1ccaa5212ea"
+ assert groups[1].name == "group1"
+ assert groups[1].user_count == 1
+ assert groups[2].id == "baf0ed9d-c25d-4114-97ed-5232b8a732fd"
+ assert groups[2].name == "test"
+ assert groups[2].user_count == 0
diff --git a/test/test_group_model.py b/test/test_group_model.py
index 617a5d954..659a3611f 100644
--- a/test/test_group_model.py
+++ b/test/test_group_model.py
@@ -1,18 +1,9 @@
import unittest
+
import tableauserverclient as TSC
class GroupModelTests(unittest.TestCase):
- def test_invalid_name(self):
- self.assertRaises(ValueError, TSC.GroupItem, None)
- self.assertRaises(ValueError, TSC.GroupItem, "")
- group = TSC.GroupItem("grp")
- with self.assertRaises(ValueError):
- group.name = None
-
- with self.assertRaises(ValueError):
- group.name = ""
-
def test_invalid_minimum_site_role(self):
group = TSC.GroupItem("grp")
with self.assertRaises(ValueError):
diff --git a/test/test_groupsets.py b/test/test_groupsets.py
new file mode 100644
index 000000000..5479809d2
--- /dev/null
+++ b/test/test_groupsets.py
@@ -0,0 +1,139 @@
+from pathlib import Path
+import unittest
+
+from defusedxml.ElementTree import fromstring
+import requests_mock
+
+import tableauserverclient as TSC
+from tableauserverclient.models.reference_item import ResourceReference
+
+TEST_ASSET_DIR = Path(__file__).parent / "assets"
+GROUPSET_CREATE = TEST_ASSET_DIR / "groupsets_create.xml"
+GROUPSETS_GET = TEST_ASSET_DIR / "groupsets_get.xml"
+GROUPSET_GET_BY_ID = TEST_ASSET_DIR / "groupsets_get_by_id.xml"
+GROUPSET_UPDATE = TEST_ASSET_DIR / "groupsets_get_by_id.xml"
+
+
+class TestGroupSets(unittest.TestCase):
+ def setUp(self) -> None:
+ self.server = TSC.Server("http://test", False)
+ self.server.version = "3.22"
+
+ # Fake signin
+ self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67"
+ self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM"
+
+ self.baseurl = self.server.group_sets.baseurl
+
+ def test_get(self) -> None:
+ with requests_mock.mock() as m:
+ m.get(self.baseurl, text=GROUPSETS_GET.read_text())
+ groupsets, pagination_item = self.server.group_sets.get()
+
+ assert len(groupsets) == 3
+ assert pagination_item.total_available == 3
+ assert groupsets[0].id == "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
+ assert groupsets[0].name == "All Users"
+ assert groupsets[0].group_count == 1
+ assert groupsets[0].groups[0].name == "group-one"
+ assert groupsets[0].groups[0].id == "gs-1"
+
+ assert groupsets[1].id == "9a8a7b6b-5c4c-3d2d-1e0e-9a8a7b6b5b4b"
+ assert groupsets[1].name == "active-directory-group-import"
+ assert groupsets[1].group_count == 1
+ assert groupsets[1].groups[0].name == "group-two"
+ assert groupsets[1].groups[0].id == "gs21"
+
+ assert groupsets[2].id == "7b6b59a8-ac3c-4d1d-2e9e-0b5b4ba8a7b6"
+ assert groupsets[2].name == "local-group-license-on-login"
+ assert groupsets[2].group_count == 1
+ assert groupsets[2].groups[0].name == "group-three"
+ assert groupsets[2].groups[0].id == "gs-3"
+
+ def test_get_by_id(self) -> None:
+ with requests_mock.mock() as m:
+ m.get(f"{self.baseurl}/1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", text=GROUPSET_GET_BY_ID.read_text())
+ groupset = self.server.group_sets.get_by_id("1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d")
+
+ assert groupset.id == "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
+ assert groupset.name == "All Users"
+ assert groupset.group_count == 3
+ assert len(groupset.groups) == 3
+
+ assert groupset.groups[0].name == "group-one"
+ assert groupset.groups[0].id == "gs-1"
+ assert groupset.groups[1].name == "group-two"
+ assert groupset.groups[1].id == "gs21"
+ assert groupset.groups[2].name == "group-three"
+ assert groupset.groups[2].id == "gs-3"
+
+ def test_update(self) -> None:
+ id_ = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
+ groupset = TSC.GroupSetItem("All Users")
+ groupset.id = id_
+ with requests_mock.mock() as m:
+ m.put(f"{self.baseurl}/{id_}", text=GROUPSET_UPDATE.read_text())
+ groupset = self.server.group_sets.update(groupset)
+
+ assert groupset.id == id_
+ assert groupset.name == "All Users"
+ assert groupset.group_count == 3
+ assert len(groupset.groups) == 3
+
+ assert groupset.groups[0].name == "group-one"
+ assert groupset.groups[0].id == "gs-1"
+ assert groupset.groups[1].name == "group-two"
+ assert groupset.groups[1].id == "gs21"
+ assert groupset.groups[2].name == "group-three"
+ assert groupset.groups[2].id == "gs-3"
+
+ def test_create(self) -> None:
+ groupset = TSC.GroupSetItem("All Users")
+ with requests_mock.mock() as m:
+ m.post(self.baseurl, text=GROUPSET_CREATE.read_text())
+ groupset = self.server.group_sets.create(groupset)
+
+ assert groupset.id == "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
+ assert groupset.name == "All Users"
+ assert groupset.group_count == 0
+ assert len(groupset.groups) == 0
+
+ def test_add_group(self) -> None:
+ groupset = TSC.GroupSetItem("All")
+ groupset.id = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
+ group = TSC.GroupItem("Example")
+ group._id = "ef8b19c0-43b6-11e6-af50-63f5805dbe3c"
+
+ with requests_mock.mock() as m:
+ m.put(f"{self.baseurl}/{groupset.id}/groups/{group._id}")
+ self.server.group_sets.add_group(groupset, group)
+
+ history = m.request_history
+
+ assert len(history) == 1
+ assert history[0].method == "PUT"
+ assert history[0].url == f"{self.baseurl}/{groupset.id}/groups/{group._id}"
+
+ def test_remove_group(self) -> None:
+ groupset = TSC.GroupSetItem("All")
+ groupset.id = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
+ group = TSC.GroupItem("Example")
+ group._id = "ef8b19c0-43b6-11e6-af50-63f5805dbe3c"
+
+ with requests_mock.mock() as m:
+ m.delete(f"{self.baseurl}/{groupset.id}/groups/{group._id}")
+ self.server.group_sets.remove_group(groupset, group)
+
+ history = m.request_history
+
+ assert len(history) == 1
+ assert history[0].method == "DELETE"
+ assert history[0].url == f"{self.baseurl}/{groupset.id}/groups/{group._id}"
+
+ def test_as_reference(self) -> None:
+ groupset = TSC.GroupSetItem()
+ groupset.id = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
+ ref = groupset.as_reference(groupset.id)
+ assert ref.id == groupset.id
+ assert ref.tag_name == groupset.tag_name
+ assert isinstance(ref, ResourceReference)
diff --git a/test/test_job.py b/test/test_job.py
index 70bca996c..b3d7007aa 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')
-
-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_COMPLETED_XML = "job_get_by_id_completed.xml"
+GET_BY_ID_FAILED_XML = "job_get_by_id_failed.xml"
+GET_BY_ID_CANCELLED_XML = "job_get_by_id_cancelled.xml"
+GET_BY_ID_INPROGRESS_XML = "job_get_by_id_inprogress.xml"
+GET_BY_ID_WORKBOOK = "job_get_by_id_failed_workbook.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,119 @@ 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(f"{self.baseurl}/{job_id}", text=response_xml)
job = self.server.jobs.get_by_id(job_id)
+ updated_at = datetime(2020, 5, 13, 20, 25, 18, tzinfo=utc)
self.assertEqual(job_id, job.id)
- self.assertListEqual(job.notes, ['Job detail notes'])
+ self.assertEqual(updated_at, job.updated_at)
+ 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(f"{self.baseurl}/{job_id}", text=response_xml)
job = self.server.jobs.wait_for_job(job_id)
self.assertEqual(job_id, job.id)
- self.assertListEqual(job.notes, ['Job detail notes'])
+ self.assertListEqual(job.notes, ["Job detail notes"])
+ def test_wait_for_job_completed(self) -> None:
+ # Waiting for a bridge (cloud) job completion
+ response_xml = read_xml_asset(GET_BY_ID_COMPLETED_XML)
+ job_id = "2eef4225-aa0c-41c4-8662-a76d89ed7336"
+ with mocked_time(), requests_mock.mock() as m:
+ m.get(f"{self.baseurl}/{job_id}", text=response_xml)
+ job = self.server.jobs.wait_for_job(job_id)
+
+ self.assertEqual(job_id, job.id)
+ self.assertListEqual(job.notes, ["Job detail notes"])
- def test_wait_for_job_failed(self):
+ 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(f"{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(f"{self.baseurl}/{job_id}", text=response_xml)
with self.assertRaises(TimeoutError):
self.server.jobs.wait_for_job(job_id, timeout=30)
+
+ def test_get_job_datasource_id(self) -> None:
+ response_xml = read_xml_asset(GET_BY_ID_FAILED_XML)
+ job_id = "777bf7c4-421d-4b2c-a518-11b90187c545"
+ with requests_mock.mock() as m:
+ m.get(f"{self.baseurl}/{job_id}", text=response_xml)
+ job = self.server.jobs.get_by_id(job_id)
+ self.assertEqual(job.datasource_id, "03b9fbec-81f6-4160-ae49-5f9f6d412758")
+
+ def test_get_job_workbook_id(self) -> None:
+ response_xml = read_xml_asset(GET_BY_ID_WORKBOOK)
+ job_id = "bb1aab79-db54-4e96-9dd3-461d8f081d08"
+ with requests_mock.mock() as m:
+ m.get(f"{self.baseurl}/{job_id}", text=response_xml)
+ job = self.server.jobs.get_by_id(job_id)
+ self.assertEqual(job.workbook_id, "5998aaaf-1abe-4d38-b4d9-bc53e85bdd13")
+
+ def test_get_job_workbook_name(self) -> None:
+ response_xml = read_xml_asset(GET_BY_ID_WORKBOOK)
+ job_id = "bb1aab79-db54-4e96-9dd3-461d8f081d08"
+ with requests_mock.mock() as m:
+ m.get(f"{self.baseurl}/{job_id}", text=response_xml)
+ job = self.server.jobs.get_by_id(job_id)
+ self.assertEqual(job.workbook_name, "Superstore")
+
+ def test_get_job_datasource_name(self) -> None:
+ response_xml = read_xml_asset(GET_BY_ID_FAILED_XML)
+ job_id = "777bf7c4-421d-4b2c-a518-11b90187c545"
+ with requests_mock.mock() as m:
+ m.get(f"{self.baseurl}/{job_id}", text=response_xml)
+ job = self.server.jobs.get_by_id(job_id)
+ self.assertEqual(job.datasource_name, "World Indicators")
+
+ def test_background_job_str(self) -> None:
+ job = TSC.BackgroundJobItem(
+ "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", datetime.now(), 1, "extractRefresh", "Failed"
+ )
+ assert not str(job).startswith("< None:
+ self.server = TSC.Server("http://test", False)
+ self.server.version = "3.15"
+
+ # Fake signin
+ self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67"
+ self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM"
+
+ self.baseurl = self.server.linked_tasks.baseurl
+
+ def test_parse_linked_task_flow_run(self):
+ xml = fromstring(GET_LINKED_TASKS.read_bytes())
+ task_runs = LinkedTaskFlowRunItem._parse_element(xml, self.server.namespace)
+ assert 1 == len(task_runs)
+ task = task_runs[0]
+ assert task.flow_run_id == "e3d1fc25-5644-4e32-af35-58dcbd1dbd73"
+ assert task.flow_run_priority == 1
+ assert task.flow_run_consecutive_failed_count == 3
+ assert task.flow_run_task_type == "runFlow"
+ assert task.flow_id == "ab1231eb-b8ca-461e-a131-83f3c2b6a673"
+ assert task.flow_name == "flow-name"
+
+ def test_parse_linked_task_step(self):
+ xml = fromstring(GET_LINKED_TASKS.read_bytes())
+ steps = LinkedTaskStepItem.from_task_xml(xml, self.server.namespace)
+ assert 1 == len(steps)
+ step = steps[0]
+ assert step.id == "f554a4df-bb6f-4294-94ee-9a709ef9bda0"
+ assert step.stop_downstream_on_failure
+ assert step.step_number == 1
+ assert 1 == len(step.task_details)
+ task = step.task_details[0]
+ assert task.flow_run_id == "e3d1fc25-5644-4e32-af35-58dcbd1dbd73"
+ assert task.flow_run_priority == 1
+ assert task.flow_run_consecutive_failed_count == 3
+ assert task.flow_run_task_type == "runFlow"
+ assert task.flow_id == "ab1231eb-b8ca-461e-a131-83f3c2b6a673"
+ assert task.flow_name == "flow-name"
+
+ def test_parse_linked_task(self):
+ tasks = LinkedTaskItem.from_response(GET_LINKED_TASKS.read_bytes(), self.server.namespace)
+ assert 1 == len(tasks)
+ task = tasks[0]
+ assert task.id == "1b8211dc-51a8-45ce-a831-b5921708e03e"
+ assert task.num_steps == 1
+ assert task.schedule is not None
+ assert task.schedule.id == "be077332-d01d-481b-b2f3-917e463d4dca"
+
+ def test_get_linked_tasks(self):
+ with requests_mock.mock() as m:
+ m.get(self.baseurl, text=GET_LINKED_TASKS.read_text())
+ tasks, pagination_item = self.server.linked_tasks.get()
+
+ assert 1 == len(tasks)
+ task = tasks[0]
+ assert task.id == "1b8211dc-51a8-45ce-a831-b5921708e03e"
+ assert task.num_steps == 1
+ assert task.schedule is not None
+ assert task.schedule.id == "be077332-d01d-481b-b2f3-917e463d4dca"
+
+ def test_get_by_id_str_linked_task(self):
+ id_ = "1b8211dc-51a8-45ce-a831-b5921708e03e"
+
+ with requests_mock.mock() as m:
+ m.get(f"{self.baseurl}/{id_}", text=GET_LINKED_TASKS.read_text())
+ task = self.server.linked_tasks.get_by_id(id_)
+
+ assert task.id == "1b8211dc-51a8-45ce-a831-b5921708e03e"
+ assert task.num_steps == 1
+ assert task.schedule is not None
+ assert task.schedule.id == "be077332-d01d-481b-b2f3-917e463d4dca"
+
+ def test_get_by_id_obj_linked_task(self):
+ id_ = "1b8211dc-51a8-45ce-a831-b5921708e03e"
+ in_task = LinkedTaskItem()
+ in_task.id = id_
+
+ with requests_mock.mock() as m:
+ m.get(f"{self.baseurl}/{id_}", text=GET_LINKED_TASKS.read_text())
+ task = self.server.linked_tasks.get_by_id(in_task)
+
+ assert task.id == "1b8211dc-51a8-45ce-a831-b5921708e03e"
+ assert task.num_steps == 1
+ assert task.schedule is not None
+ assert task.schedule.id == "be077332-d01d-481b-b2f3-917e463d4dca"
+
+ def test_run_now_str_linked_task(self):
+ id_ = "1b8211dc-51a8-45ce-a831-b5921708e03e"
+
+ with requests_mock.mock() as m:
+ m.post(f"{self.baseurl}/{id_}/runNow", text=RUN_LINKED_TASK_NOW.read_text())
+ job = self.server.linked_tasks.run_now(id_)
+
+ assert job.id == "269a1e5a-1220-4a13-ac01-704982693dd8"
+ assert job.status == "InProgress"
+ assert job.created_at == parse_datetime("2022-02-15T00:22:22Z")
+ assert job.linked_task_id == id_
+
+ def test_run_now_obj_linked_task(self):
+ id_ = "1b8211dc-51a8-45ce-a831-b5921708e03e"
+ in_task = LinkedTaskItem()
+ in_task.id = id_
+
+ with requests_mock.mock() as m:
+ m.post(f"{self.baseurl}/{id_}/runNow", text=RUN_LINKED_TASK_NOW.read_text())
+ job = self.server.linked_tasks.run_now(in_task)
+
+ assert job.id == "269a1e5a-1220-4a13-ac01-704982693dd8"
+ assert job.status == "InProgress"
+ assert job.created_at == parse_datetime("2022-02-15T00:22:22Z")
+ assert job.linked_task_id == id_
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..1836095bb 100644
--- a/test/test_pager.py
+++ b/test/test_pager.py
@@ -1,32 +1,49 @@
-import unittest
+import contextlib
import os
+import unittest
+import xml.etree.ElementTree as ET
+
import requests_mock
+
import tableauserverclient as TSC
+from tableauserverclient.config import config
-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_VIEW_XML = os.path.join(TEST_ASSET_DIR, "view_get.xml")
+GET_XML_PAGE1 = os.path.join(TEST_ASSET_DIR, "workbook_get_page_1.xml")
+GET_XML_PAGE2 = os.path.join(TEST_ASSET_DIR, "workbook_get_page_2.xml")
+GET_XML_PAGE3 = os.path.join(TEST_ASSET_DIR, "workbook_get_page_3.xml")
+
+
+@contextlib.contextmanager
+def set_env(**environ):
+ old_environ = dict(os.environ)
+ os.environ.update(environ)
+ try:
+ yield
+ finally:
+ os.environ.clear()
+ os.environ.update(old_environ)
class PagerTests(unittest.TestCase):
def setUp(self):
- 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')
+ def test_pager_with_no_options(self) -> None:
+ with open(GET_XML_PAGE1, "rb") as f:
+ page_1 = f.read().decode("utf-8")
+ with open(GET_XML_PAGE2, "rb") as f:
+ 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 +59,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')
-
- 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')
+ self.assertEqual(wb1.name, "Page1Workbook")
+ self.assertEqual(wb2.name, "Page2Workbook")
+ self.assertEqual(wb3.name, "Page3Workbook")
+
+ def test_pager_with_options(self) -> None:
+ with open(GET_XML_PAGE1, "rb") as f:
+ page_1 = f.read().decode("utf-8")
+ with open(GET_XML_PAGE2, "rb") as f:
+ 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 +84,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 +102,35 @@ 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")
+
+ def test_pager_with_env_var(self) -> None:
+ with set_env(TSC_PAGE_SIZE="1000"):
+ assert config.PAGE_SIZE == 1000
+ loop = TSC.Pager(self.server.workbooks)
+ assert loop._options.pagesize == 1000
+
+ def test_queryset_with_env_var(self) -> None:
+ with set_env(TSC_PAGE_SIZE="1000"):
+ assert config.PAGE_SIZE == 1000
+ loop = self.server.workbooks.all()
+ assert loop.request_options.pagesize == 1000
+
+ def test_pager_view(self) -> None:
+ with open(GET_VIEW_XML, "rb") as f:
+ view_xml = f.read().decode("utf-8")
+ with requests_mock.mock() as m:
+ m.get(self.server.views.baseurl, text=view_xml)
+ for view in TSC.Pager(self.server.views):
+ assert view.name is not None
+
+ def test_queryset_no_matches(self) -> None:
+ elem = ET.Element("tsResponse", xmlns="http://tableau.com/api")
+ ET.SubElement(elem, "pagination", totalAvailable="0")
+ ET.SubElement(elem, "groups")
+ xml = ET.tostring(elem).decode("utf-8")
+ with requests_mock.mock() as m:
+ m.get(self.server.groups.baseurl, text=xml)
+ all_groups = self.server.groups.all()
+ groups = list(all_groups)
+ assert len(groups) == 0
diff --git a/test/test_permissionsrule.py b/test/test_permissionsrule.py
new file mode 100644
index 000000000..d7bceb258
--- /dev/null
+++ b/test/test_permissionsrule.py
@@ -0,0 +1,104 @@
+import unittest
+
+import tableauserverclient as TSC
+from tableauserverclient.models.reference_item import ResourceReference
+
+
+class TestPermissionsRules(unittest.TestCase):
+ def test_and(self):
+ grantee = ResourceReference("a", "user")
+ rule1 = TSC.PermissionsRule(
+ grantee,
+ {
+ TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny,
+ TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny,
+ },
+ )
+ rule2 = TSC.PermissionsRule(
+ grantee,
+ {
+ TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny,
+ },
+ )
+
+ composite = rule1 & rule2
+
+ self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ExportData), TSC.Permission.Mode.Allow)
+ self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.Delete), TSC.Permission.Mode.Deny)
+ self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ViewComments), None)
+ self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ExportXml), TSC.Permission.Mode.Deny)
+
+ def test_or(self):
+ grantee = ResourceReference("a", "user")
+ rule1 = TSC.PermissionsRule(
+ grantee,
+ {
+ TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny,
+ TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny,
+ },
+ )
+ rule2 = TSC.PermissionsRule(
+ grantee,
+ {
+ TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny,
+ },
+ )
+
+ composite = rule1 | rule2
+
+ self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ExportData), TSC.Permission.Mode.Allow)
+ self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.Delete), TSC.Permission.Mode.Allow)
+ self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ViewComments), TSC.Permission.Mode.Allow)
+ self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ExportXml), TSC.Permission.Mode.Deny)
+
+ def test_eq_false(self):
+ grantee = ResourceReference("a", "user")
+ rule1 = TSC.PermissionsRule(
+ grantee,
+ {
+ TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny,
+ TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny,
+ },
+ )
+ rule2 = TSC.PermissionsRule(
+ grantee,
+ {
+ TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny,
+ },
+ )
+
+ self.assertNotEqual(rule1, rule2)
+
+ def test_eq_true(self):
+ grantee = ResourceReference("a", "user")
+ rule1 = TSC.PermissionsRule(
+ grantee,
+ {
+ TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny,
+ TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny,
+ },
+ )
+ rule2 = TSC.PermissionsRule(
+ grantee,
+ {
+ TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny,
+ TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny,
+ },
+ )
+ self.assertEqual(rule1, rule2)
diff --git a/test/test_project.py b/test/test_project.py
index be43b063e..c51f2e1e6 100644
--- a/test/test_project.py
+++ b/test/test_project.py
@@ -1,220 +1,236 @@
-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 tableauserverclient import GroupItem
+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")
+GET_XML_ALL_FIELDS = asset("project_get_all_fields.xml")
+UPDATE_XML = asset("project_update.xml")
+SET_CONTENT_PERMISSIONS_XML = asset("project_content_permission.xml")
+CREATE_XML = asset("project_create.xml")
+POPULATE_PERMISSIONS_XML = "project_populate_permissions.xml"
+POPULATE_WORKBOOK_DEFAULT_PERMISSIONS_XML = "project_populate_workbook_default_permissions.xml"
+UPDATE_DATASOURCE_DEFAULT_PERMISSIONS_XML = "project_update_datasource_default_permissions.xml"
+POPULATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML = "project_populate_virtualconnection_default_permissions.xml"
+UPDATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML = "project_update_virtualconnection_default_permissions.xml"
class ProjectTests(unittest.TestCase):
- 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.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794"
single_project = self.server.projects.update(single_project)
- self.assertEqual('1d0304cd-3796-429f-b815-7258370b9b74', single_project.id)
- 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)
+ self.assertEqual("dd2239f6-ddf1-4107-981a-4cf94e415794", single_project.owner_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=GroupItem.as_reference(group._id), 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:
+ 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 +242,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=GroupItem.as_reference(single_group._id), capabilities=capabilities)
- endpoint = '{}/permissions/groups/{}'.format(single_project._id, single_group._id)
- m.delete('{}/{}/Read/Allow'.format(self.baseurl, endpoint), status_code=204)
- m.delete('{}/{}/Write/Allow'.format(self.baseurl, endpoint), status_code=204)
+ endpoint = f"{single_project._id}/permissions/groups/{single_group._id}"
+ m.delete(f"{self.baseurl}/{endpoint}/Read/Allow", status_code=204)
+ m.delete(f"{self.baseurl}/{endpoint}/Write/Allow", status_code=204)
self.server.projects.delete_permission(item=single_project, rules=rules)
- def test_delete_workbook_default_permission(self):
- 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 +275,164 @@ 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
+ rules = TSC.PermissionsRule(grantee=GroupItem.as_reference(single_group._id), capabilities=capabilities)
+
+ endpoint = f"{single_project._id}/default-permissions/workbooks/groups/{single_group._id}"
+ m.delete(f"{self.baseurl}/{endpoint}/Read/Allow", status_code=204)
+ m.delete(f"{self.baseurl}/{endpoint}/ExportImage/Allow", status_code=204)
+ m.delete(f"{self.baseurl}/{endpoint}/ExportData/Allow", status_code=204)
+ m.delete(f"{self.baseurl}/{endpoint}/ViewComments/Allow", status_code=204)
+ m.delete(f"{self.baseurl}/{endpoint}/AddComment/Allow", status_code=204)
+ m.delete(f"{self.baseurl}/{endpoint}/Filter/Allow", status_code=204)
+ m.delete(f"{self.baseurl}/{endpoint}/ViewUnderlyingData/Deny", status_code=204)
+ m.delete(f"{self.baseurl}/{endpoint}/ShareView/Allow", status_code=204)
+ m.delete(f"{self.baseurl}/{endpoint}/WebAuthoring/Allow", status_code=204)
+ m.delete(f"{self.baseurl}/{endpoint}/Write/Allow", status_code=204)
+ m.delete(f"{self.baseurl}/{endpoint}/ExportXml/Allow", status_code=204)
+ m.delete(f"{self.baseurl}/{endpoint}/ChangeHierarchy/Allow", status_code=204)
+ m.delete(f"{self.baseurl}/{endpoint}/Delete/Deny", status_code=204)
+ m.delete(f"{self.baseurl}/{endpoint}/ChangePermissions/Allow", status_code=204)
+ self.server.projects.delete_workbook_default_permissions(item=single_project, rule=rules)
+
+ def test_populate_virtualconnection_default_permissions(self):
+ response_xml = read_xml_asset(POPULATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML)
+
+ self.server.version = "3.23"
+ base_url = self.server.projects.baseurl
+
+ with requests_mock.mock() as m:
+ m.get(
+ base_url + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/virtualConnections",
+ text=response_xml,
)
+ project = TSC.ProjectItem("test", "1d0304cd-3796-429f-b815-7258370b9b74")
+ project._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb"
- 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)
+ self.server.projects.populate_virtualconnection_default_permissions(project)
+ permissions = project.default_virtualconnection_permissions
+
+ rule = permissions.pop()
+
+ self.assertEqual("c8f2773a-c83a-11e8-8c8f-33e6d787b506", rule.grantee.id)
+ self.assertEqual("group", rule.grantee.tag_name)
+ self.assertDictEqual(
+ rule.capabilities,
+ {
+ TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.Connect: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Deny,
+ TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny,
+ TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Deny,
+ },
+ )
+
+ def test_update_virtualconnection_default_permissions(self):
+ response_xml = read_xml_asset(UPDATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML)
+
+ self.server.version = "3.23"
+ base_url = self.server.projects.baseurl
+
+ with requests_mock.mock() as m:
+ m.put(
+ base_url + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/virtualConnections",
+ text=response_xml,
+ )
+ project = TSC.ProjectItem("test", "1d0304cd-3796-429f-b815-7258370b9b74")
+ project._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb"
+
+ group = TSC.GroupItem("test-group")
+ group._id = "c8f2773a-c83a-11e8-8c8f-33e6d787b506"
+
+ capabilities = {
+ TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.Read: TSC.Permission.Mode.Deny,
+ }
+
+ rules = [TSC.PermissionsRule(GroupItem.as_reference(group.id), capabilities)]
+ new_rules = self.server.projects.update_virtualconnection_default_permissions(project, rules)
+
+ rule = new_rules.pop()
+
+ self.assertEqual(group.id, rule.grantee.id)
+ self.assertEqual("group", rule.grantee.tag_name)
+ self.assertDictEqual(
+ rule.capabilities,
+ {
+ TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.Read: TSC.Permission.Mode.Deny,
+ },
+ )
+
+ def test_delete_virtualconnection_default_permimssions(self):
+ response_xml = read_xml_asset(POPULATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML)
+
+ self.server.version = "3.23"
+ base_url = self.server.projects.baseurl
+
+ with requests_mock.mock() as m:
+ m.get(
+ base_url + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/virtualConnections",
+ text=response_xml,
+ )
+
+ project = TSC.ProjectItem("test", "1d0304cd-3796-429f-b815-7258370b9b74")
+ project._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb"
+
+ group = TSC.GroupItem("test-group")
+ group._id = "c8f2773a-c83a-11e8-8c8f-33e6d787b506"
+
+ self.server.projects.populate_virtualconnection_default_permissions(project)
+ permissions = project.default_virtualconnection_permissions
+
+ del_caps = {
+ TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Deny,
+ TSC.Permission.Capability.Connect: TSC.Permission.Mode.Allow,
+ }
+
+ rule = TSC.PermissionsRule(GroupItem.as_reference(group.id), del_caps)
+
+ endpoint = f"{project.id}/default-permissions/virtualConnections/groups/{group.id}"
+ m.delete(f"{base_url}/{endpoint}/ChangeHierarchy/Deny", status_code=204)
+ m.delete(f"{base_url}/{endpoint}/Connect/Allow", status_code=204)
+
+ self.server.projects.delete_virtualconnection_default_permissions(project, rule)
+
+ def test_get_all_fields(self) -> None:
+ self.server.version = "3.23"
+ base_url = self.server.projects.baseurl
+ with open(GET_XML_ALL_FIELDS, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+
+ ro = TSC.RequestOptions()
+ ro.all_fields = True
+
+ with requests_mock.mock() as m:
+ m.get(f"{base_url}?fields=_all_", text=response_xml)
+ all_projects, pagination_item = self.server.projects.get(req_options=ro)
+
+ assert pagination_item.total_available == 3
+ assert len(all_projects) == 1
+ project: TSC.ProjectItem = all_projects[0]
+ assert isinstance(project, TSC.ProjectItem)
+ assert project.id == "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760"
+ assert project.name == "Samples"
+ assert project.description == "This project includes automatically uploaded samples."
+ assert project.top_level_project is True
+ assert project.content_permissions == "ManagedByOwner"
+ assert project.parent_id is None
+ assert project.writeable is True
diff --git a/test/test_project_model.py b/test/test_project_model.py
index 55cf20b26..ecfe1bd14 100644
--- a/test/test_project_model.py
+++ b/test/test_project_model.py
@@ -1,17 +1,14 @@
import unittest
+
import tableauserverclient as TSC
class ProjectModelTests(unittest.TestCase):
- def test_invalid_name(self):
- self.assertRaises(ValueError, TSC.ProjectItem, None)
- self.assertRaises(ValueError, TSC.ProjectItem, "")
+ def test_nullable_name(self):
+ TSC.ProjectItem(None)
+ TSC.ProjectItem("")
project = TSC.ProjectItem("proj")
- with self.assertRaises(ValueError):
- project.name = None
-
- with self.assertRaises(ValueError):
- project.name = ""
+ project.name = None
def test_invalid_content_permissions(self):
project = TSC.ProjectItem("proj")
@@ -22,8 +19,3 @@ def test_parent_id(self):
project = TSC.ProjectItem("proj")
project.parent_id = "foo"
self.assertEqual(project.parent_id, "foo")
-
- def test_owner_id(self):
- project = TSC.ProjectItem("proj")
- with self.assertRaises(NotImplementedError):
- project.owner_id = "new_owner"
diff --git a/test/test_regression_tests.py b/test/test_regression_tests.py
index 281f3fbca..62e301591 100644
--- a/test/test_regression_tests.py
+++ b/test/test_regression_tests.py
@@ -1,63 +1,83 @@
import unittest
-
-try:
- from unittest import mock
-except ImportError:
- import mock
+from unittest import mock
import tableauserverclient.server.request_factory as factory
-from tableauserverclient.server.endpoint import Endpoint
+from tableauserverclient.helpers.strings import redact_xml
from tableauserverclient.filesys_helpers import to_filename, make_download_path
class BugFix257(unittest.TestCase):
def test_empty_request_works(self):
result = factory.EmptyRequest().empty_req()
- self.assertEqual(b'', result)
-
-
-class BugFix273(unittest.TestCase):
- def test_binary_log_truncated(self):
-
- class FakeResponse(object):
-
- headers = {'Content-Type': 'application/octet-stream'}
- content = b'\x1337' * 1000
- status_code = 200
-
- server_response = FakeResponse()
-
- self.assertEqual(Endpoint._safe_to_log(server_response), '[Truncated File Contents]')
+ self.assertEqual(b"", result)
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))
+
+
+class LoggingTest(unittest.TestCase):
+ def test_redact_password_string(self):
+ redacted = redact_xml(
+ "this is password: my_super_secret_passphrase_which_nobody_should_ever_see password: value"
+ )
+ assert redacted.find("value") == -1
+ assert redacted.find("secret") == -1
+ assert redacted.find("ever_see") == -1
+ assert redacted.find("my_super_secret_passphrase_which_nobody_should_ever_see") == -1
+
+ def test_redact_password_bytes(self):
+ redacted = redact_xml(
+ b""
+ )
+ assert redacted.find(b"value") == -1
+ assert redacted.find(b"secret") == -1
+
+ def test_redact_password_with_special_char(self):
+ redacted = redact_xml(
+ " "
+ )
+ assert redacted.find("my_s per_secre>_passphrase_which_nobody_should_ever_see with password: value") == -1
+
+ def test_redact_password_not_xml(self):
+ redacted = redact_xml(
+ " "
+ )
+ assert redacted.find("my_s per_secre>_passphrase_which_nobody_should_ever_see") == -1
+
+ def test_redact_password_really_not_xml(self):
+ redacted = redact_xml(
+ "value='this is a nondescript text line which is public' password='my_s per_secre>_passphrase_which_nobody_should_ever_see with password: value and then a cookie "
+ )
+ assert redacted.find("my_s per_secre>_passphrase_which_nobody_should_ever_see") == -1
+ assert redacted.find("passphrase") == -1, redacted
+ assert redacted.find("cookie") == -1, redacted
diff --git a/test/test_request_option.py b/test/test_request_option.py
index 37b4fc945..57dfdc2a0 100644
--- a/test/test_request_option.py
+++ b/test/test_request_option.py
@@ -1,36 +1,43 @@
-import unittest
import os
+from pathlib import Path
import re
-import requests
+import unittest
+from urllib.parse import parse_qs
+
import requests_mock
+
import tableauserverclient as TSC
-TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets')
+TEST_ASSET_DIR = Path(__file__).parent / "assets"
-PAGINATION_XML = os.path.join(TEST_ASSET_DIR, 'request_option_pagination.xml')
-PAGE_NUMBER_XML = os.path.join(TEST_ASSET_DIR, 'request_option_page_number.xml')
-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_NAME_IN = os.path.join(TEST_ASSET_DIR, "request_option_filter_name_in.xml")
+FILTER_TAGS_IN = os.path.join(TEST_ASSET_DIR, "request_option_filter_tags_in.xml")
+FILTER_MULTIPLE = os.path.join(TEST_ASSET_DIR, "request_option_filter_tags_in.xml")
+SLICING_QUERYSET = os.path.join(TEST_ASSET_DIR, "request_option_slicing_queryset.xml")
+SLICING_QUERYSET_PAGE_1 = TEST_ASSET_DIR / "queryset_slicing_page_1.xml"
+SLICING_QUERYSET_PAGE_2 = TEST_ASSET_DIR / "queryset_slicing_page_2.xml"
class RequestOptionTests(unittest.TestCase):
- def setUp(self):
- self.server = TSC.Server('http://test')
+ def setUp(self) -> None:
+ self.server = TSC.Server("http://test", False, http_options={"timeout": 5})
# 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 = f"{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')
+ def test_pagination(self) -> None:
+ with open(PAGINATION_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
with requests_mock.mock() as m:
- 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)
@@ -39,11 +46,11 @@ def test_pagination(self):
self.assertEqual(33, pagination_item.total_available)
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')
+ def test_page_number(self) -> None:
+ with open(PAGE_NUMBER_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
with requests_mock.mock() as m:
- 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)
@@ -52,11 +59,11 @@ def test_page_number(self):
self.assertEqual(210, pagination_item.total_available)
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')
+ def test_page_size(self) -> None:
+ with open(PAGE_SIZE_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
with requests_mock.mock() as m:
- 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)
@@ -65,117 +72,151 @@ def test_page_size(self):
self.assertEqual(33, pagination_item.total_available)
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')
+ def test_filter_equals(self) -> None:
+ with open(FILTER_EQUALS, "rb") as f:
+ response_xml = f.read().decode("utf-8")
with requests_mock.mock() as m:
- 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')
+ def test_filter_equals_shorthand(self) -> None:
+ with open(FILTER_EQUALS, "rb") as f:
+ response_xml = f.read().decode("utf-8")
with requests_mock.mock() as m:
- 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')
+ def test_filter_tags_in(self) -> None:
+ with open(FILTER_TAGS_IN, "rb") as f:
+ response_xml = f.read().decode("utf-8")
with requests_mock.mock() as m:
- 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({"weather"}, matching_workbooks[0].tags)
+ self.assertEqual({"safari"}, matching_workbooks[1].tags)
+ self.assertEqual({"sample"}, matching_workbooks[2].tags)
+
+ # check if filtered projects with spaces & special characters
+ # get correctly returned
+ def test_filter_name_in(self) -> None:
+ with open(FILTER_NAME_IN, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+ with requests_mock.mock() as m:
+ m.get(
+ self.baseurl + "/projects?filter=name%3Ain%3A%5Bdefault%2CSalesforce+Sales+Proje%C5%9Bt%5D",
+ text=response_xml,
+ )
+ req_option = TSC.RequestOptions()
+ req_option.filter.add(
+ TSC.Filter(
+ TSC.RequestOptions.Field.Name,
+ TSC.RequestOptions.Operator.In,
+ ["default", "Salesforce Sales Projeśt"],
+ )
+ )
+ matching_projects, pagination_item = self.server.projects.get(req_option)
+
+ self.assertEqual(2, pagination_item.total_available)
+ self.assertEqual("default", matching_projects[0].name)
+ self.assertEqual("Salesforce Sales Projeśt", matching_projects[1].name)
- def test_filter_tags_in_shorthand(self):
- with open(FILTER_TAGS_IN, 'rb') as f:
- response_xml = f.read().decode('utf-8')
+ def test_filter_tags_in_shorthand(self) -> None:
+ with open(FILTER_TAGS_IN, "rb") as f:
+ response_xml = f.read().decode("utf-8")
with requests_mock.mock() as m:
- 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({"weather"}, matching_workbooks[0].tags)
+ self.assertEqual({"safari"}, matching_workbooks[1].tags)
+ self.assertEqual({"sample"}, matching_workbooks[2].tags)
- def test_invalid_shorthand_option(self):
+ def test_invalid_shorthand_option(self) -> None:
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')
+ def test_multiple_filter_options(self) -> None:
+ with open(FILTER_MULTIPLE, "rb") as f:
+ response_xml = f.read().decode("utf-8")
# To ensure that this is deterministic, run this a few times
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'))
- for _ in range(100):
+ 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(5):
matching_workbooks, pagination_item = self.server.workbooks.get(req_option)
self.assertEqual(3, pagination_item.total_available)
# Test req_options if url already has query params
- def test_double_query_params(self):
+ def test_double_query_params(self) -> None:
with requests_mock.mock() as m:
m.get(requests_mock.ANY)
url = self.baseurl + "/views?queryParamExists=true"
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):
+ def test_filter_sort_legacy(self) -> None:
self.server.version = "3.6"
with requests_mock.mock() as m:
m.get(requests_mock.ANY)
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):
+ def test_vf(self) -> None:
with requests_mock.mock() as m:
m.get(requests_mock.ANY)
url = self.baseurl + "/views/456/data"
@@ -185,12 +226,12 @@ 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):
+ def test_vf_legacy(self) -> None:
self.server.version = "3.6"
with requests_mock.mock() as m:
m.get(requests_mock.ANY)
@@ -201,32 +242,139 @@ 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):
+ def test_all_fields(self) -> None:
with requests_mock.mock() as m:
m.get(requests_mock.ANY)
url = self.baseurl + "/views/456/data"
opts = TSC.RequestOptions()
- opts._all_fields = True
+ opts.all_fields = True
resp = self.server.users.get_request(url, request_object=opts)
- self.assertTrue(re.search('fields=_all_', resp.request.query))
+ 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')
+ def test_multiple_filter_options_shorthand(self) -> None:
+ with open(FILTER_MULTIPLE, "rb") as f:
+ response_xml = f.read().decode("utf-8")
# To ensure that this is deterministic, run this a few times
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'
- )
+ for _ in range(5):
+ 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) -> None:
+ 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_slicing_queryset_multi_page(self) -> None:
+ with requests_mock.mock() as m:
+ m.get(self.baseurl + "/views?pageNumber=1", text=SLICING_QUERYSET_PAGE_1.read_text())
+ m.get(self.baseurl + "/views?pageNumber=2", text=SLICING_QUERYSET_PAGE_2.read_text())
+ sliced_views = self.server.views.all()[9:12]
+
+ self.assertEqual(sliced_views[0].id, "2e6d6c81-da71-4b41-892c-ba80d4e7a6d0")
+ self.assertEqual(sliced_views[1].id, "47ffcb8e-3f7a-4ecf-8ab3-605da9febe20")
+ self.assertEqual(sliced_views[2].id, "6757fea8-0aa9-4160-a87c-9be27b1d1c8c")
+
+ def test_queryset_filter_args_error(self) -> None:
+ with self.assertRaises(RuntimeError):
+ workbooks = self.server.workbooks.filter("argument")
+
+ def test_filtering_parameters(self) -> None:
+ self.server.version = "3.6"
+ with requests_mock.mock() as m:
+ m.get(requests_mock.ANY)
+ url = self.baseurl + "/views/456/data"
+ opts = TSC.PDFRequestOptions()
+ opts.parameter("name1@", "value1")
+ opts.parameter("name2$", "value2")
+ opts.page_type = TSC.PDFRequestOptions.PageType.Tabloid
+
+ resp = self.server.workbooks.get_request(url, request_object=opts)
+ query_params = parse_qs(resp.request.query)
+ self.assertIn("name1@", query_params)
+ self.assertIn("value1", query_params["name1@"])
+ self.assertIn("name2$", query_params)
+ self.assertIn("value2", query_params["name2$"])
+ self.assertIn("type", query_params)
+ self.assertIn("tabloid", query_params["type"])
+
+ def test_queryset_endpoint_pagesize_all(self) -> None:
+ for page_size in (1, 10, 100, 1000):
+ with self.subTest(page_size):
+ with requests_mock.mock() as m:
+ m.get(f"{self.baseurl}/views?pageSize={page_size}", text=SLICING_QUERYSET_PAGE_1.read_text())
+ queryset = self.server.views.all(page_size=page_size)
+ assert queryset.request_options.pagesize == page_size
+ _ = list(queryset)
+
+ def test_queryset_endpoint_pagesize_filter(self) -> None:
+ for page_size in (1, 10, 100, 1000):
+ with self.subTest(page_size):
+ with requests_mock.mock() as m:
+ m.get(f"{self.baseurl}/views?pageSize={page_size}", text=SLICING_QUERYSET_PAGE_1.read_text())
+ queryset = self.server.views.filter(page_size=page_size)
+ assert queryset.request_options.pagesize == page_size
+ _ = list(queryset)
+
+ def test_queryset_pagesize_filter(self) -> None:
+ for page_size in (1, 10, 100, 1000):
+ with self.subTest(page_size):
+ with requests_mock.mock() as m:
+ m.get(f"{self.baseurl}/views?pageSize={page_size}", text=SLICING_QUERYSET_PAGE_1.read_text())
+ queryset = self.server.views.all().filter(page_size=page_size)
+ assert queryset.request_options.pagesize == page_size
+ _ = list(queryset)
+
+ def test_language_export(self) -> None:
+ with requests_mock.mock() as m:
+ m.get(requests_mock.ANY)
+ url = self.baseurl + "/views/456/data"
+ opts = TSC.PDFRequestOptions()
+ opts.language = "en-US"
+
+ resp = self.server.users.get_request(url, request_object=opts)
+ self.assertTrue(re.search("language=en-us", resp.request.query))
+
+ def test_queryset_fields(self) -> None:
+ loop = self.server.users.fields("id")
+ assert "id" in loop.request_options.fields
+ assert "_default_" in loop.request_options.fields
+
+ def test_queryset_only_fields(self) -> None:
+ loop = self.server.users.only_fields("id")
+ assert "id" in loop.request_options.fields
+ assert "_default_" not in loop.request_options.fields
diff --git a/test/test_requests.py b/test/test_requests.py
index 2976e8f3e..5c0d090ba 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,31 @@ 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.assertTrue(re.search("Tableau Server Client", resp.request.headers["user-agent"]))
+ self.assertEqual(resp.request.body, b"1337")
# Test that 500 server errors are handled properly
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 +57,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..4fcc85e18 100644
--- a/test/test_schedule.py
+++ b/test/test_schedule.py
@@ -1,13 +1,20 @@
-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_HOURLY_ID_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_hourly_id.xml")
+GET_DAILY_ID_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_daily_id.xml")
+GET_MONTHLY_ID_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_monthly_id.xml")
+GET_MONTHLY_ID_2_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_monthly_id_2.xml")
GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_empty.xml")
CREATE_HOURLY_XML = os.path.join(TEST_ASSET_DIR, "schedule_create_hourly.xml")
CREATE_DAILY_XML = os.path.join(TEST_ASSET_DIR, "schedule_create_daily.xml")
@@ -17,14 +24,17 @@
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")
+GET_EXTRACT_TASKS_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_extract_refresh_tasks.xml")
-WORKBOOK_GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, 'workbook_get_by_id.xml')
-DATASOURCE_GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, 'datasource_get_by_id.xml')
+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 +42,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:
@@ -42,6 +52,7 @@ def test_get(self):
extract = all_schedules[0]
subscription = all_schedules[1]
flow = all_schedules[2]
+ system = all_schedules[3]
self.assertEqual(2, pagination_item.total_available)
self.assertEqual("c9cff7f9-309c-4361-99ff-d4ba8c9f5467", extract.id)
@@ -71,7 +82,16 @@ 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):
+ self.assertEqual("3cfa4713-ce7c-4fa7-aa2e-f752bfc8dd04", system.id)
+ self.assertEqual("First of the month 2:00AM", system.name)
+ self.assertEqual("Active", system.state)
+ self.assertEqual(30, system.priority)
+ self.assertEqual("2019-02-19T18:52:19Z", format_datetime(system.created_at))
+ self.assertEqual("2019-02-19T18:55:51Z", format_datetime(system.updated_at))
+ self.assertEqual("System", system.schedule_type)
+ self.assertEqual("2019-03-01T09:00:00Z", format_datetime(system.next_run_at))
+
+ def test_get_empty(self) -> None:
with open(GET_EMPTY_XML, "rb") as f:
response_xml = f.read().decode("utf-8")
with requests_mock.mock() as m:
@@ -81,21 +101,98 @@ 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 = f"{self.server.baseurl}/schedules/{schedule_id}"
+ m.get(baseurl, text=response_xml)
+ schedule = self.server.schedules.get_by_id(schedule_id)
+ self.assertIsNotNone(schedule)
+ self.assertEqual(schedule_id, schedule.id)
+ self.assertEqual("Weekday early mornings", schedule.name)
+ self.assertEqual("Active", schedule.state)
+
+ def test_get_hourly_by_id(self) -> None:
+ self.server.version = "3.8"
+ with open(GET_HOURLY_ID_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+ with requests_mock.mock() as m:
+ schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467"
+ baseurl = f"{self.server.baseurl}/schedules/{schedule_id}"
+ m.get(baseurl, text=response_xml)
+ schedule = self.server.schedules.get_by_id(schedule_id)
+ self.assertIsNotNone(schedule)
+ self.assertEqual(schedule_id, schedule.id)
+ self.assertEqual("Hourly schedule", schedule.name)
+ self.assertEqual("Active", schedule.state)
+ self.assertEqual(("Monday", 0.5), schedule.interval_item.interval)
+
+ def test_get_daily_by_id(self) -> None:
+ self.server.version = "3.8"
+ with open(GET_DAILY_ID_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+ with requests_mock.mock() as m:
+ schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467"
+ baseurl = f"{self.server.baseurl}/schedules/{schedule_id}"
+ m.get(baseurl, text=response_xml)
+ schedule = self.server.schedules.get_by_id(schedule_id)
+ self.assertIsNotNone(schedule)
+ self.assertEqual(schedule_id, schedule.id)
+ self.assertEqual("Daily schedule", schedule.name)
+ self.assertEqual("Active", schedule.state)
+ self.assertEqual(("Monday", 2.0), schedule.interval_item.interval)
+
+ def test_get_monthly_by_id(self) -> None:
+ self.server.version = "3.8"
+ with open(GET_MONTHLY_ID_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+ with requests_mock.mock() as m:
+ schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467"
+ baseurl = f"{self.server.baseurl}/schedules/{schedule_id}"
+ m.get(baseurl, text=response_xml)
+ schedule = self.server.schedules.get_by_id(schedule_id)
+ self.assertIsNotNone(schedule)
+ self.assertEqual(schedule_id, schedule.id)
+ self.assertEqual("Monthly multiple days", schedule.name)
+ self.assertEqual("Active", schedule.state)
+ self.assertEqual(("1", "2"), schedule.interval_item.interval)
+
+ def test_get_monthly_by_id_2(self) -> None:
+ self.server.version = "3.15"
+ with open(GET_MONTHLY_ID_2_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+ with requests_mock.mock() as m:
+ schedule_id = "8c5caf33-6223-4724-83c3-ccdc1e730a07"
+ baseurl = f"{self.server.baseurl}/schedules/{schedule_id}"
+ m.get(baseurl, text=response_xml)
+ schedule = self.server.schedules.get_by_id(schedule_id)
+ self.assertIsNotNone(schedule)
+ self.assertEqual(schedule_id, schedule.id)
+ self.assertEqual("Monthly First Monday!", schedule.name)
+ self.assertEqual("Active", schedule.state)
+ self.assertEqual(("Monday", "First"), schedule.interval_item.interval)
+
+ def test_delete(self) -> None:
with requests_mock.mock() as m:
m.delete(self.baseurl + "/c9cff7f9-309c-4361-99ff-d4ba8c9f5467", status_code=204)
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 +205,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 +234,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 +261,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 +291,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 +318,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,60 +334,92 @@ 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)
+ baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules"
with open(WORKBOOK_GET_BY_ID_XML, "rb") as f:
workbook_response = f.read().decode("utf-8")
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)
+ baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules"
with open(WORKBOOK_GET_BY_ID_XML, "rb") as f:
workbook_response = f.read().decode("utf-8")
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)
+ baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules"
with open(DATASOURCE_GET_BY_ID_XML, "rb") as f:
datasource_response = f.read().decode("utf-8")
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 = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules"
+
+ with open(FLOW_GET_BY_ID_XML, "rb") as f:
+ flow_response = f.read().decode("utf-8")
+ 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")
+
+ def test_get_extract_refresh_tasks(self) -> None:
+ self.server.version = "2.3"
+
+ with open(GET_EXTRACT_TASKS_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+ with requests_mock.mock() as m:
+ schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467"
+ baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules/{schedule_id}/extracts"
+ m.get(baseurl, text=response_xml)
+
+ extracts = self.server.schedules.get_extract_refresh_tasks(schedule_id)
+
+ self.assertIsNotNone(extracts)
+ self.assertIsInstance(extracts[0], list)
+ self.assertEqual(2, len(extracts[0]))
+ self.assertEqual("task1", extracts[0][0].id)
diff --git a/test/test_server_info.py b/test/test_server_info.py
index 3dadff7c1..fa1472c9a 100644
--- a/test/test_server_info.py
+++ b/test/test_server_info.py
@@ -1,62 +1,75 @@
-import unittest
import os.path
+import unittest
+
import requests_mock
+
import tableauserverclient as TSC
+from tableauserverclient.server.endpoint.exceptions import NonXMLResponseError
-TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets')
+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")
+SERVER_INFO_WRONG_SITE = os.path.join(TEST_ASSET_DIR, "server_info_wrong_site.html")
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("3.10", actual.rest_api_version)
def test_server_info_use_highest_version_downgrades(self):
- with open(SERVER_INFO_AUTH_INFO_XML, 'rb') as f:
+ 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')
+ # does server-version[9.2] lookup in PRODUCT_TO_REST_VERSION
+ self.assertEqual(self.server.version, "2.2")
def test_server_info_use_highest_version_upgrades(self):
- with open(SERVER_INFO_GET_XML, 'rb') as f:
- si_response_xml = f.read().decode('utf-8')
+ with open(SERVER_INFO_GET_XML, "rb") as f:
+ si_response_xml = f.read().decode("utf-8")
with requests_mock.mock() as m:
- m.get(self.server.server_address + "/api/2.4/serverInfo", text=si_response_xml)
+ m.get(self.server.server_address + "/api/2.8/serverInfo", text=si_response_xml)
# Pretend we're old
- self.server.version = '2.0'
+ self.server.version = "2.8"
self.server.use_server_version()
- # Did we upgrade to 2.4?
- self.assertEqual(self.server.version, '2.4')
+ # Did we upgrade to 3.10?
+ self.assertEqual(self.server.version, "3.10")
def test_server_use_server_version_flag(self):
- with open(SERVER_INFO_25_XML, 'rb') as f:
- 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")
+
+ def test_server_wrong_site(self):
+ with open(SERVER_INFO_WRONG_SITE, "rb") as f:
+ response = f.read().decode("utf-8")
with requests_mock.mock() as m:
- m.get('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(self.server.server_info.baseurl, text=response, status_code=404)
+ with self.assertRaises(NonXMLResponseError):
+ self.server.server_info.get()
diff --git a/test/test_site.py b/test/test_site.py
index 8fbb4eda3..243810254 100644
--- a/test/test_site.py
+++ b/test/test_site.py
@@ -1,39 +1,46 @@
-import unittest
import os.path
+import unittest
+
+import pytest
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")
+SITE_AUTH_CONFIG_XML = os.path.join(TEST_ASSET_DIR, "site_auth_configurations.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')
+ # sites APIs can only be called on the site being logged in to
+ self.logged_in_site = self.server.site_id
+
+ def test_get(self) -> None:
+ with open(GET_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
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 +50,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 +68,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 + "/" + self.logged_in_site, text=response_xml)
+ single_site = self.server.sites.get_by_id(self.logged_in_site)
- self.assertEqual('dad65087-b08b-4603-af4e-2887b8aafc67', single_site.id)
- self.assertEqual('Active', single_site.state)
- self.assertEqual('Default', single_site.name)
- self.assertEqual('ContentOnly', single_site.admin_mode)
+ self.assertEqual(self.logged_in_site, single_site.id)
+ self.assertEqual("Active", single_site.state)
+ self.assertEqual("Default", single_site.name)
+ self.assertEqual("ContentOnly", single_site.admin_mode)
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 +90,83 @@ 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(self.logged_in_site, single_site.id)
+ self.assertEqual("Active", single_site.state)
+ self.assertEqual("testsite", single_site.name)
+ self.assertEqual("ContentOnly", single_site.admin_mode)
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')
+ @pytest.mark.filterwarnings("ignore:Tiered license level is set")
+ @pytest.mark.filterwarnings("ignore:FlowsEnabled has been removed")
+ def test_update(self) -> None:
+ with open(UPDATE_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
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 + "/" + self.logged_in_site, 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 = self.logged_in_site
+ self.server.sites.parent_srv = self.server
single_site = self.server.sites.update(single_site)
- self.assertEqual('6b7179ba-b82b-4f0f-91ed-812074ac5da6', single_site.id)
- self.assertEqual('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(self.logged_in_site, single_site.id)
+ self.assertEqual("tableau", single_site.content_url)
+ self.assertEqual("Suspended", single_site.state)
+ self.assertEqual("Tableau", single_site.name)
+ 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 +175,114 @@ 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)
+
+ @pytest.mark.filterwarnings("ignore:FlowsEnabled has been removed")
+ def test_create(self) -> None:
+ with open(CREATE_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
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")
+
+ def test_list_auth_configurations(self) -> None:
+ self.server.version = "3.24"
+ self.baseurl = self.server.sites.baseurl
+ with open(SITE_AUTH_CONFIG_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+
+ assert self.baseurl == self.server.sites.baseurl
+
+ with requests_mock.mock() as m:
+ m.get(f"{self.baseurl}/{self.server.site_id}/site-auth-configurations", status_code=200, text=response_xml)
+ configs = self.server.sites.list_auth_configurations()
+
+ assert len(configs) == 2, "Expected 2 auth configurations"
+
+ assert configs[0].auth_setting == "OIDC"
+ assert configs[0].enabled
+ assert configs[0].idp_configuration_id == "00000000-0000-0000-0000-000000000000"
+ assert configs[0].idp_configuration_name == "Initial Salesforce"
+ assert configs[0].known_provider_alias == "Salesforce"
+ assert configs[1].auth_setting == "SAML"
+ assert configs[1].enabled
+ assert configs[1].idp_configuration_id == "11111111-1111-1111-1111-111111111111"
+ assert configs[1].idp_configuration_name == "Initial SAML"
+ assert configs[1].known_provider_alias is None
diff --git a/test/test_site_model.py b/test/test_site_model.py
index 99fa73ce9..60ad9c5e5 100644
--- a/test/test_site_model.py
+++ b/test/test_site_model.py
@@ -1,6 +1,5 @@
-# coding=utf-8
-
import unittest
+
import tableauserverclient as TSC
@@ -21,7 +20,6 @@ def test_invalid_admin_mode(self):
site.admin_mode = "Hello"
def test_invalid_content_url(self):
-
with self.assertRaises(ValueError):
site = TSC.SiteItem(name="蚵仔煎", content_url="蚵仔煎")
diff --git a/test/test_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_ssl_config.py b/test/test_ssl_config.py
new file mode 100644
index 000000000..036a326ca
--- /dev/null
+++ b/test/test_ssl_config.py
@@ -0,0 +1,77 @@
+import unittest
+import ssl
+from unittest.mock import patch, MagicMock
+from tableauserverclient import Server
+from tableauserverclient.server.endpoint import Endpoint
+import logging
+
+
+class TestSSLConfig(unittest.TestCase):
+ @patch("requests.session")
+ @patch("tableauserverclient.server.endpoint.Endpoint.set_parameters")
+ def setUp(self, mock_set_parameters, mock_session):
+ """Set up test fixtures with mocked session and request validation"""
+ # Mock the session
+ self.mock_session = MagicMock()
+ mock_session.return_value = self.mock_session
+
+ # Mock request preparation
+ self.mock_request = MagicMock()
+ self.mock_session.prepare_request.return_value = self.mock_request
+
+ # Create server instance with mocked components
+ self.server = Server("http://test")
+
+ def test_default_ssl_config(self):
+ """Test that by default, no custom SSL context is used"""
+ self.assertIsNone(self.server._ssl_context)
+ self.assertNotIn("verify", self.server.http_options)
+
+ @patch("ssl.create_default_context")
+ def test_weak_dh_config(self, mock_create_context):
+ """Test that weak DH keys can be allowed when configured"""
+ # Setup mock SSL context
+ mock_context = MagicMock()
+ mock_create_context.return_value = mock_context
+
+ # Configure SSL with weak DH
+ self.server.configure_ssl(allow_weak_dh=True)
+
+ # Verify SSL context was created and configured correctly
+ mock_create_context.assert_called_once()
+ mock_context.set_dh_parameters.assert_called_once_with(min_key_bits=512)
+
+ # Verify context was added to http options
+ self.assertEqual(self.server.http_options["verify"], mock_context)
+
+ @patch("ssl.create_default_context")
+ def test_disable_weak_dh_config(self, mock_create_context):
+ """Test that SSL config can be reset to defaults"""
+ # Setup mock SSL context
+ mock_context = MagicMock()
+ mock_create_context.return_value = mock_context
+
+ # First enable weak DH
+ self.server.configure_ssl(allow_weak_dh=True)
+ self.assertIsNotNone(self.server._ssl_context)
+ self.assertIn("verify", self.server.http_options)
+
+ # Then disable it
+ self.server.configure_ssl(allow_weak_dh=False)
+ self.assertIsNone(self.server._ssl_context)
+ self.assertNotIn("verify", self.server.http_options)
+
+ @patch("ssl.create_default_context")
+ def test_warning_on_weak_dh(self, mock_create_context):
+ """Test that a warning is logged when enabling weak DH keys"""
+ logging.getLogger().setLevel(logging.WARNING)
+ with self.assertLogs(level="WARNING") as log:
+ self.server.configure_ssl(allow_weak_dh=True)
+ self.assertTrue(
+ any("WARNING: Allowing weak Diffie-Hellman keys" in record for record in log.output),
+ "Expected warning about weak DH keys was not logged",
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_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..195bcf0a9 100644
--- a/test/test_tableauauth_model.py
+++ b/test/test_tableauauth_model.py
@@ -1,25 +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):
TSC.TableauAuth()
-
- def test_site_arg_raises_warning(self):
- with warnings.catch_warnings(record=True) as w:
- warnings.simplefilter("always")
-
- tableau_auth = TSC.TableauAuth('user',
- 'password',
- site='Default')
-
- self.assertTrue(any(item.category == DeprecationWarning for item in w))
diff --git a/test/test_tagging.py b/test/test_tagging.py
new file mode 100644
index 000000000..23dffebfb
--- /dev/null
+++ b/test/test_tagging.py
@@ -0,0 +1,230 @@
+from contextlib import ExitStack
+import re
+from collections.abc import Iterable
+import uuid
+from xml.etree import ElementTree as ET
+
+import pytest
+import requests_mock
+import tableauserverclient as TSC
+
+
+@pytest.fixture
+def get_server() -> TSC.Server:
+ server = TSC.Server("http://test", False)
+
+ # Fake sign in
+ server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67"
+ server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM"
+ server.version = "3.28"
+ return server
+
+
+def add_tag_xml_response_factory(tags: Iterable[str]) -> str:
+ root = ET.Element("tsResponse")
+ tags_element = ET.SubElement(root, "tags")
+ for tag in tags:
+ tag_element = ET.SubElement(tags_element, "tag")
+ tag_element.attrib["label"] = tag
+ root.attrib["xmlns"] = "http://tableau.com/api"
+ return ET.tostring(root, encoding="utf-8").decode("utf-8")
+
+
+def batch_add_tags_xml_response_factory(tags, content):
+ root = ET.Element("tsResponse")
+ tag_batch = ET.SubElement(root, "tagBatch")
+ tags_element = ET.SubElement(tag_batch, "tags")
+ for tag in tags:
+ tag_element = ET.SubElement(tags_element, "tag")
+ tag_element.attrib["label"] = tag
+ contents_element = ET.SubElement(tag_batch, "contents")
+ for item in content:
+ content_elem = ET.SubElement(contents_element, "content")
+ content_elem.attrib["id"] = item.id or "some_id"
+ t = item.__class__.__name__.replace("Item", "") or ""
+ content_elem.attrib["contentType"] = t
+ root.attrib["xmlns"] = "http://tableau.com/api"
+ return ET.tostring(root, encoding="utf-8").decode("utf-8")
+
+
+def make_workbook() -> TSC.WorkbookItem:
+ workbook = TSC.WorkbookItem("project", "test")
+ workbook._id = str(uuid.uuid4())
+ return workbook
+
+
+def make_view() -> TSC.ViewItem:
+ view = TSC.ViewItem()
+ view._id = str(uuid.uuid4())
+ return view
+
+
+def make_datasource() -> TSC.DatasourceItem:
+ datasource = TSC.DatasourceItem("project", "test")
+ datasource._id = str(uuid.uuid4())
+ return datasource
+
+
+def make_table() -> TSC.TableItem:
+ table = TSC.TableItem("project", "test")
+ table._id = str(uuid.uuid4())
+ return table
+
+
+def make_database() -> TSC.DatabaseItem:
+ database = TSC.DatabaseItem("project", "test")
+ database._id = str(uuid.uuid4())
+ return database
+
+
+def make_flow() -> TSC.FlowItem:
+ flow = TSC.FlowItem("project", "test")
+ flow._id = str(uuid.uuid4())
+ return flow
+
+
+def make_vconn() -> TSC.VirtualConnectionItem:
+ vconn = TSC.VirtualConnectionItem("test")
+ vconn._id = str(uuid.uuid4())
+ return vconn
+
+
+sample_taggable_items = (
+ [
+ ("workbooks", make_workbook()),
+ ("workbooks", "some_id"),
+ ("views", make_view()),
+ ("views", "some_id"),
+ ("datasources", make_datasource()),
+ ("datasources", "some_id"),
+ ("tables", make_table()),
+ ("tables", "some_id"),
+ ("databases", make_database()),
+ ("databases", "some_id"),
+ ("flows", make_flow()),
+ ("flows", "some_id"),
+ ("virtual_connections", make_vconn()),
+ ("virtual_connections", "some_id"),
+ ],
+)
+
+sample_tags = [
+ "a",
+ ["a", "b"],
+ ["a", "b", "c", "c"],
+]
+
+
+@pytest.mark.parametrize("endpoint_type, item", *sample_taggable_items)
+@pytest.mark.parametrize("tags", sample_tags)
+def test_add_tags(get_server, endpoint_type, item, tags) -> None:
+ add_tags_xml = add_tag_xml_response_factory(tags)
+ endpoint = getattr(get_server, endpoint_type)
+ id_ = getattr(item, "id", item)
+
+ with requests_mock.mock() as m:
+ m.put(
+ f"{endpoint.baseurl}/{id_}/tags",
+ status_code=200,
+ text=add_tags_xml,
+ )
+ tag_result = endpoint.add_tags(item, tags)
+
+ if isinstance(tags, str):
+ tags = [tags]
+ assert set(tag_result) == set(tags)
+
+
+@pytest.mark.parametrize("endpoint_type, item", *sample_taggable_items)
+@pytest.mark.parametrize("tags", sample_tags)
+def test_delete_tags(get_server, endpoint_type, item, tags) -> None:
+ add_tags_xml = add_tag_xml_response_factory(tags)
+ endpoint = getattr(get_server, endpoint_type)
+ id_ = getattr(item, "id", item)
+
+ if isinstance(tags, str):
+ tags = [tags]
+ tag_paths = "|".join(tags)
+ tag_paths = f"({tag_paths})"
+ matcher = re.compile(rf"{endpoint.baseurl}\/{id_}\/tags\/{tag_paths}")
+ with requests_mock.mock() as m:
+ m.delete(
+ matcher,
+ status_code=200,
+ text=add_tags_xml,
+ )
+ endpoint.delete_tags(item, tags)
+ history = m.request_history
+
+ tag_set = set(tags)
+ assert len(history) == len(tag_set)
+ urls = {r.url.split("/")[-1] for r in history}
+ assert urls == tag_set
+
+
+@pytest.mark.parametrize("endpoint_type, item", *sample_taggable_items)
+@pytest.mark.parametrize("tags", sample_tags)
+def test_update_tags(get_server, endpoint_type, item, tags) -> None:
+ endpoint = getattr(get_server, endpoint_type)
+ id_ = getattr(item, "id", item)
+ tags = set([tags] if isinstance(tags, str) else tags)
+ with ExitStack() as stack:
+ if isinstance(item, str):
+ stack.enter_context(pytest.raises((ValueError, NotImplementedError)))
+ elif hasattr(item, "_initial_tags"):
+ initial_tags = {"x", "y", "z"}
+ item._initial_tags = initial_tags
+ add_tags_xml = add_tag_xml_response_factory(tags - initial_tags)
+ delete_tags_xml = add_tag_xml_response_factory(initial_tags - tags)
+ m = stack.enter_context(requests_mock.mock())
+ m.put(
+ f"{endpoint.baseurl}/{id_}/tags",
+ status_code=200,
+ text=add_tags_xml,
+ )
+
+ tag_paths = "|".join(initial_tags - tags)
+ tag_paths = f"({tag_paths})"
+ matcher = re.compile(rf"{endpoint.baseurl}\/{id_}\/tags\/{tag_paths}")
+ m.delete(
+ matcher,
+ status_code=200,
+ text=delete_tags_xml,
+ )
+
+ else:
+ stack.enter_context(pytest.raises(NotImplementedError))
+
+ endpoint.update_tags(item)
+
+
+def test_tags_batch_add(get_server) -> None:
+ server = get_server
+ content = [make_workbook(), make_view(), make_datasource(), make_table(), make_database()]
+ tags = ["a", "b"]
+ add_tags_xml = batch_add_tags_xml_response_factory(tags, content)
+ with requests_mock.mock() as m:
+ m.put(
+ f"{server.tags.baseurl}:batchCreate",
+ status_code=200,
+ text=add_tags_xml,
+ )
+ tag_result = server.tags.batch_add(tags, content)
+
+ assert set(tag_result) == set(tags)
+
+
+def test_tags_batch_delete(get_server) -> None:
+ server = get_server
+ content = [make_workbook(), make_view(), make_datasource(), make_table(), make_database()]
+ tags = ["a", "b"]
+ add_tags_xml = batch_add_tags_xml_response_factory(tags, content)
+ with requests_mock.mock() as m:
+ m.put(
+ f"{server.tags.baseurl}:batchDelete",
+ status_code=200,
+ text=add_tags_xml,
+ )
+ tag_result = server.tags.batch_delete(tags, content)
+
+ assert set(tag_result) == set(tags)
diff --git a/test/test_task.py b/test/test_task.py
index 566167d4a..2d724b879 100644
--- a/test/test_task.py
+++ b/test/test_task.py
@@ -1,11 +1,15 @@
-import unittest
import os
+import unittest
+from datetime import time
+from pathlib import Path
+
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")
+TEST_ASSET_DIR = Path(__file__).parent / "assets"
GET_XML_NO_WORKBOOK = os.path.join(TEST_ASSET_DIR, "tasks_no_workbook_or_datasource.xml")
GET_XML_WITH_WORKBOOK = os.path.join(TEST_ASSET_DIR, "tasks_with_workbook.xml")
@@ -13,12 +17,15 @@
GET_XML_WITH_WORKBOOK_AND_DATASOURCE = os.path.join(TEST_ASSET_DIR, "tasks_with_workbook_and_datasource.xml")
GET_XML_DATAACCELERATION_TASK = os.path.join(TEST_ASSET_DIR, "tasks_with_dataacceleration_task.xml")
GET_XML_RUN_NOW_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_run_now_response.xml")
+GET_XML_CREATE_TASK_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_create_extract_task.xml")
+GET_XML_WITHOUT_SCHEDULE = TEST_ASSET_DIR / "tasks_without_schedule.xml"
+GET_XML_WITH_INTERVAL = TEST_ASSET_DIR / "tasks_with_interval.xml"
class TaskTests(unittest.TestCase):
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.19"
# Fake Signin
self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67"
@@ -45,8 +52,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 +63,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 +73,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 +85,105 @@ 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_get_task_without_schedule(self):
+ with requests_mock.mock() as m:
+ m.get(self.baseurl, text=GET_XML_WITHOUT_SCHEDULE.read_text())
+ all_tasks, pagination_item = self.server.tasks.get()
+
+ task = all_tasks[0]
+ self.assertEqual("c7a9327e-1cda-4504-b026-ddb43b976d1d", task.target.id)
+ self.assertEqual("datasource", task.target.type)
+
+ def test_get_task_with_interval(self):
+ with requests_mock.mock() as m:
+ m.get(self.baseurl, text=GET_XML_WITH_INTERVAL.read_text())
+ all_tasks, pagination_item = self.server.tasks.get()
+
+ task = all_tasks[0]
+ self.assertEqual("e4de0575-fcc7-4232-5659-be09bb8e7654", task.target.id)
+ self.assertEqual("datasource", task.target.type)
def test_delete(self):
with requests_mock.mock() as m:
- m.delete(self.baseurl + '/c7a9327e-1cda-4504-b026-ddb43b976d1d', status_code=204)
- 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(f"{self.server.tasks.baseurl}/{TaskItem.Type.DataAcceleration}", text=response_xml)
all_tasks, pagination_item = self.server.tasks.get(task_type=TaskItem.Type.DataAcceleration)
task = all_tasks[0]
- 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(f"{self.baseurl}/{task_id}", text=response_xml)
task = self.server.tasks.get_by_id(task_id)
- self.assertEqual('c7a9327e-1cda-4504-b026-ddb43b976d1d', task.target.id)
- 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(f"{self.baseurl}/{task_id}/runNow", text=response_xml)
job_response_content = self.server.tasks.run(task).decode("utf-8")
- self.assertTrue('7b6b59a8-ac3c-4d1d-2e9e-0b5b4ba8a7b6' in job_response_content)
- 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)
+
+ def test_create_extract_task(self):
+ monthly_interval = TSC.MonthlyInterval(start_time=time(23, 30), interval_value=15)
+ monthly_schedule = TSC.ScheduleItem(
+ None,
+ None,
+ None,
+ None,
+ monthly_interval,
+ )
+ target_item = TSC.Target("workbook_id", "workbook")
+
+ task = TaskItem(None, "FullRefresh", None, schedule_item=monthly_schedule, target=target_item)
+
+ with open(GET_XML_CREATE_TASK_RESPONSE, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+ with requests_mock.mock() as m:
+ m.post(f"{self.baseurl}", text=response_xml)
+ create_response_content = self.server.tasks.create(task).decode("utf-8")
+
+ self.assertTrue("task_id" in create_response_content)
+ self.assertTrue("workbook_id" in create_response_content)
+ self.assertTrue("FullRefresh" in create_response_content)
diff --git a/test/test_user.py b/test/test_user.py
index e4d1d6717..fa2ac3a12 100644
--- a/test/test_user.py
+++ b/test/test_user.py
@@ -1,34 +1,41 @@
-import unittest
import os
+import unittest
+
+from defusedxml import ElementTree as ET
import requests_mock
+
import tableauserverclient as TSC
-from tableauserverclient.datetime_helpers import format_datetime
+from tableauserverclient.datetime_helpers import format_datetime, parse_datetime
+
+TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets")
-TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets')
+GET_XML = os.path.join(TEST_ASSET_DIR, "user_get.xml")
+GET_XML_ALL_FIELDS = os.path.join(TEST_ASSET_DIR, "user_get_all_fields.xml")
+GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "user_get_empty.xml")
+GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "user_get_by_id.xml")
+UPDATE_XML = os.path.join(TEST_ASSET_DIR, "user_update.xml")
+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')
+USERNAMES = os.path.join(TEST_ASSET_DIR, "Data", "usernames.csv")
+USERS = os.path.join(TEST_ASSET_DIR, "Data", "user_details.csv")
class UserTests(unittest.TestCase):
- def setUp(self):
- 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 +43,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 +68,255 @@ 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')
-
- def test_remove_missing_id(self):
- self.assertRaises(ValueError, self.server.users.remove, '')
+ m.delete(self.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794", status_code=204)
+ self.server.users.remove("dd2239f6-ddf1-4107-981a-4cf94e415794")
- def test_add(self):
- with open(ADD_XML, 'rb') as f:
- response_xml = f.read().decode('utf-8')
+ def test_remove_with_replacement(self) -> None:
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.delete(
+ self.baseurl
+ + "/dd2239f6-ddf1-4107-981a-4cf94e415794"
+ + "?mapAssetsTo=4cc4c17f-898a-4de4-abed-a1681c673ced",
+ status_code=204,
+ )
+ self.server.users.remove("dd2239f6-ddf1-4107-981a-4cf94e415794", "4cc4c17f-898a-4de4-abed-a1681c673ced")
+
+ def test_remove_missing_id(self) -> None:
+ self.assertRaises(ValueError, self.server.users.remove, "")
+
+ 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")
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({"Safari", "Sample"}, workbook_list[0].tags)
+
+ def test_populate_owned_workbooks(self) -> None:
+ with open(POPULATE_WORKBOOKS_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+ # Query parameter ownedBy is case sensitive.
+ with requests_mock.mock(case_sensitive=True) as m:
+ m.get(self.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794/workbooks?ownedBy=true", text=response_xml)
+ single_user = TSC.UserItem("test", "Interactor")
+ single_user._id = "dd2239f6-ddf1-4107-981a-4cf94e415794"
+ self.server.users.populate_workbooks(single_user, owned_only=True)
+ list(single_user.workbooks)
+
+ request_history = m.request_history[0]
+
+ assert "ownedBy" in request_history.qs, "ownedBy not in request history"
+ assert "true" in request_history.qs["ownedBy"], "ownedBy not set to true in request history"
+
+ def test_populate_workbooks_missing_id(self) -> None:
+ single_user = TSC.UserItem("test", "Interactor")
self.assertRaises(TSC.MissingRequiredFieldError, self.server.users.populate_workbooks, single_user)
- 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(f"{baseurl}/{single_user.id}", text=response_xml)
self.server.users.populate_favorites(single_user)
self.assertIsNotNone(single_user._favorites)
- self.assertEqual(len(single_user.favorites['workbooks']), 1)
- 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("86a66d40-f289-472a-83d0-927b0f954dc8", group_list[2].id)
+ self.assertEqual("TableauExample", group_list[2].name)
+ self.assertEqual("local", group_list[2].domain_name)
+
+ def test_get_usernames_from_file(self):
+ with open(ADD_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+ with requests_mock.mock() as m:
+ m.post(self.server.users.baseurl, text=response_xml)
+ user_list, failures = self.server.users.create_from_file(USERNAMES)
+ assert user_list[0].name == "Cassie", user_list
+ assert failures == [], failures
+
+ def test_get_users_from_file(self):
+ with open(ADD_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+ with requests_mock.mock() as m:
+ m.post(self.server.users.baseurl, text=response_xml)
+ users, failures = self.server.users.create_from_file(USERS)
+ assert users[0].name == "Cassie", users
+ assert failures == []
+
+ def test_get_users_all_fields(self) -> None:
+ self.server.version = "3.7"
+ baseurl = self.server.users.baseurl
+ with open(GET_XML_ALL_FIELDS) as f:
+ response_xml = f.read()
+
+ with requests_mock.mock() as m:
+ m.get(f"{baseurl}?fields=_all_", text=response_xml)
+ all_users, _ = self.server.users.get()
+
+ assert all_users[0].auth_setting == "TableauIDWithMFA"
+ assert all_users[0].email == "bob@example.com"
+ assert all_users[0].external_auth_user_id == "38c870c3ac5e84ec66e6ced9fb23681835b07e56d5660371ac1f705cc65bd610"
+ assert all_users[0].fullname == "Bob Smith"
+ assert all_users[0].id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9"
+ assert all_users[0].last_login == parse_datetime("2025-02-04T06:39:20Z")
+ assert all_users[0].name == "bob@example.com"
+ assert all_users[0].site_role == "SiteAdministratorCreator"
+ assert all_users[0].locale is None
+ assert all_users[0].language == "en"
+ assert all_users[0].idp_configuration_id == "22222222-2222-2222-2222-222222222222"
+ assert all_users[0].domain_name == "TABID_WITH_MFA"
+ assert all_users[1].auth_setting == "TableauIDWithMFA"
+ assert all_users[1].email == "alice@example.com"
+ assert all_users[1].external_auth_user_id == "96f66b893b22669cdfa632275d354cd1d92cea0266f3be7702151b9b8c52be29"
+ assert all_users[1].fullname == "Alice Jones"
+ assert all_users[1].id == "f6d72445-285b-48e5-8380-f90b519ce682"
+ assert all_users[1].name == "alice@example.com"
+ assert all_users[1].site_role == "ExplorerCanPublish"
+ assert all_users[1].locale is None
+ assert all_users[1].language == "en"
+ assert all_users[1].idp_configuration_id == "22222222-2222-2222-2222-222222222222"
+ assert all_users[1].domain_name == "TABID_WITH_MFA"
+
+ def test_add_user_idp_configuration(self) -> None:
+ with open(ADD_XML) as f:
+ response_xml = f.read()
+ user = TSC.UserItem(name="Cassie", site_role="Viewer")
+ user.idp_configuration_id = "012345"
+
+ with requests_mock.mock() as m:
+ m.post(self.server.users.baseurl, text=response_xml)
+ user = self.server.users.add(user)
+
+ history = m.request_history[0]
+
+ tree = ET.fromstring(history.text)
+ user_elem = tree.find(".//user")
+ assert user_elem is not None
+ assert user_elem.attrib["idpConfigurationId"] == "012345"
+
+ def test_update_user_idp_configuration(self) -> None:
+ with open(ADD_XML) as f:
+ response_xml = f.read()
+ user = TSC.UserItem(name="Cassie", site_role="Viewer")
+ user._id = "0123456789"
+ user.idp_configuration_id = "012345"
+
+ with requests_mock.mock() as m:
+ m.put(f"{self.server.users.baseurl}/{user.id}", text=response_xml)
+ user = self.server.users.update(user)
- 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)
+ history = m.request_history[0]
- 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)
+ tree = ET.fromstring(history.text)
+ user_elem = tree.find(".//user")
+ assert user_elem is not None
+ assert user_elem.attrib["idpConfigurationId"] == "012345"
diff --git a/test/test_user_model.py b/test/test_user_model.py
index 5826fb148..a8a2c51cb 100644
--- a/test/test_user_model.py
+++ b/test/test_user_model.py
@@ -1,18 +1,14 @@
+import logging
import unittest
-import tableauserverclient as TSC
+from unittest.mock import *
+import io
+import pytest
-class UserModelTests(unittest.TestCase):
- def test_invalid_name(self):
- self.assertRaises(ValueError, TSC.UserItem, None, TSC.UserItem.Roles.Publisher)
- self.assertRaises(ValueError, TSC.UserItem, "", TSC.UserItem.Roles.Publisher)
- user = TSC.UserItem("me", TSC.UserItem.Roles.Publisher)
- with self.assertRaises(ValueError):
- user.name = None
+import tableauserverclient as TSC
- with self.assertRaises(ValueError):
- user.name = ""
+class UserModelTests(unittest.TestCase):
def test_invalid_auth_setting(self):
user = TSC.UserItem("me", TSC.UserItem.Roles.Publisher)
with self.assertRaises(ValueError):
@@ -22,3 +18,110 @@ def test_invalid_site_role(self):
user = TSC.UserItem("me", TSC.UserItem.Roles.Publisher)
with self.assertRaises(ValueError):
user.site_role = "Hello"
+
+
+class UserDataTest(unittest.TestCase):
+ logger = logging.getLogger("UserDataTest")
+
+ role_inputs = [
+ ["creator", "system", "yes", "SiteAdministrator"],
+ ["None", "system", "no", "SiteAdministrator"],
+ ["explorer", "SysTEm", "no", "SiteAdministrator"],
+ ["creator", "site", "yes", "SiteAdministratorCreator"],
+ ["explorer", "site", "yes", "SiteAdministratorExplorer"],
+ ["creator", "SITE", "no", "SiteAdministratorCreator"],
+ ["creator", "none", "yes", "Creator"],
+ ["explorer", "none", "yes", "ExplorerCanPublish"],
+ ["viewer", "None", "no", "Viewer"],
+ ["explorer", "no", "yes", "ExplorerCanPublish"],
+ ["EXPLORER", "noNO", "yes", "ExplorerCanPublish"],
+ ["explorer", "no", "no", "Explorer"],
+ ["unlicensed", "none", "no", "Unlicensed"],
+ ["Chef", "none", "yes", "Unlicensed"],
+ ["yes", "yes", "yes", "Unlicensed"],
+ ]
+
+ valid_import_content = [
+ "username, pword, fname, creator, site, yes, email",
+ "username, pword, fname, explorer, none, no, email",
+ "",
+ "u",
+ "p",
+ ]
+
+ valid_username_content = ["jfitzgerald@tableau.com"]
+
+ usernames = [
+ "valid",
+ "valid@email.com",
+ "domain/valid",
+ "domain/valid@tmail.com",
+ "va!@#$%^&*()lid",
+ "in@v@lid",
+ "in valid",
+ "",
+ ]
+
+ def test_validate_usernames(self):
+ TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[0])
+ TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[1])
+ TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[2])
+ TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[3])
+ TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[4])
+ with self.assertRaises(AttributeError):
+ TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[5])
+ with self.assertRaises(AttributeError):
+ TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[6])
+
+ def test_evaluate_role(self):
+ for line in UserDataTest.role_inputs:
+ actual = TSC.UserItem.CSVImport._evaluate_site_role(line[0], line[1], line[2])
+ assert actual == line[3], line + [actual]
+
+ def test_get_user_detail_empty_line(self):
+ test_line = ""
+ test_user = TSC.UserItem.CSVImport.create_user_from_line(test_line)
+ assert test_user is None
+
+ def test_get_user_detail_standard(self):
+ test_line = "username, pword, fname, license, admin, pub, email"
+ test_user: TSC.UserItem = TSC.UserItem.CSVImport.create_user_from_line(test_line)
+ assert test_user.name == "username", test_user.name
+ assert test_user.fullname == "fname", test_user.fullname
+ assert test_user.site_role == "Unlicensed", test_user.site_role
+ assert test_user.email == "email", test_user.email
+
+ def test_get_user_details_only_username(self):
+ test_line = "username"
+ test_user: TSC.UserItem = TSC.UserItem.CSVImport.create_user_from_line(test_line)
+
+ def test_populate_user_details_only_some(self):
+ values = "username, , , creator, admin"
+ user = TSC.UserItem.CSVImport.create_user_from_line(values)
+ assert user.name == "username"
+
+ def test_validate_user_detail_standard(self):
+ test_line = "username, pword, fname, creator, site, 1, email"
+ TSC.UserItem.CSVImport._validate_import_line_or_throw(test_line, UserDataTest.logger)
+ TSC.UserItem.CSVImport.create_user_from_line(test_line)
+
+ # for file handling
+ def _mock_file_content(self, content: list[str]) -> io.TextIOWrapper:
+ # the empty string represents EOF
+ # the tests run through the file twice, first to validate then to fetch
+ mock = MagicMock(io.TextIOWrapper)
+ content.append("") # EOF
+ mock.readline.side_effect = content
+ mock.name = "file-mock"
+ return mock
+
+ def test_validate_import_file(self):
+ test_data = self._mock_file_content(UserDataTest.valid_import_content)
+ valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, UserDataTest.logger)
+ assert valid == 2, f"Expected two lines to be parsed, got {valid}"
+ assert invalid == [], f"Expected no failures, got {invalid}"
+
+ def test_validate_usernames_file(self):
+ test_data = self._mock_file_content(UserDataTest.usernames)
+ valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, UserDataTest.logger)
+ assert valid == 5, f"Exactly 5 of the lines were valid, counted {valid + invalid}"
diff --git a/test/test_view.py b/test/test_view.py
index e32971ea2..ee6d518de 100644
--- a/test/test_view.py
+++ b/test/test_view.py
@@ -1,114 +1,139 @@
-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
-
-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')
+from tableauserverclient.datetime_helpers import format_datetime, parse_datetime
+from tableauserverclient.server.endpoint.exceptions import UnsupportedAttributeError
+
+TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets")
+
+ADD_TAGS_XML = os.path.join(TEST_ASSET_DIR, "view_add_tags.xml")
+GET_XML = os.path.join(TEST_ASSET_DIR, "view_get.xml")
+GET_XML_ALL_FIELDS = os.path.join(TEST_ASSET_DIR, "view_get_all_fields.xml")
+GET_XML_ID = os.path.join(TEST_ASSET_DIR, "view_get_id.xml")
+GET_XML_USAGE = os.path.join(TEST_ASSET_DIR, "view_get_usage.xml")
+GET_XML_ID_USAGE = os.path.join(TEST_ASSET_DIR, "view_get_id_usage.xml")
+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({"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({"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_usage(self) -> None:
+ with open(GET_XML_ID_USAGE, "rb") as f:
+ response_xml = f.read().decode("utf-8")
with requests_mock.mock() as m:
- m.get(self.baseurl + '/d79634e1-6063-4ec9-95ff-50acbf609ff5', 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?includeUsageStatistics=true", text=response_xml)
+ view = self.server.views.get_by_id("d79634e1-6063-4ec9-95ff-50acbf609ff5", usage=True)
+
+ self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", view.id)
+ self.assertEqual("ENDANGERED SAFARI", view.name)
+ self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", view.content_url)
+ self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", view.workbook_id)
+ self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", view.owner_id)
+ self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", view.project_id)
+ self.assertEqual({"tag1", "tag2"}, view.tags)
+ self.assertEqual("2002-05-30T09:00:00Z", format_datetime(view.created_at))
+ self.assertEqual("2002-06-05T08:00:59Z", format_datetime(view.updated_at))
+ self.assertEqual("story", view.sheet_type)
+ self.assertEqual(7, view.total_views)
+
+ def test_get_by_id_missing_id(self) -> None:
self.assertRaises(TSC.MissingRequiredFieldError, self.server.views.get_by_id, None)
- 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 +141,104 @@ 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_unsupported(self) -> None:
+ self.server.version = "3.8"
+ with open(POPULATE_PREVIEW_IMAGE, "rb") as f:
response = f.read()
with requests_mock.mock() as m:
- m.get(self.baseurl + '/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?resolution=high&maxAge=10',
- content=response)
+ m.get(
+ self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?vizWidth=1920&vizHeight=1080",
+ content=response,
+ )
single_view = TSC.ViewItem()
- single_view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5'
+ single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5"
+
+ req_option = TSC.ImageRequestOptions(viz_width=1920, viz_height=1080)
+
+ with self.assertRaises(UnsupportedAttributeError):
+ self.server.views.populate_image(single_view, req_option)
+
+ def test_populate_image_viz_dimensions(self) -> None:
+ self.server.version = "3.23"
+ self.baseurl = self.server.views.baseurl
+ with open(POPULATE_PREVIEW_IMAGE, "rb") as f:
+ response = f.read()
+ with requests_mock.mock() as m:
+ m.get(
+ self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?vizWidth=1920&vizHeight=1080",
+ content=response,
+ )
+ single_view = TSC.ViewItem()
+ single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5"
+
+ req_option = TSC.ImageRequestOptions(viz_width=1920, viz_height=1080)
+
+ self.server.views.populate_image(single_view, req_option)
+ self.assertEqual(response, single_view.image)
+
+ history = m.request_history
+
+ def test_populate_image_with_options(self) -> None:
+ with open(POPULATE_PREVIEW_IMAGE, "rb") as f:
+ response = f.read()
+ with requests_mock.mock() as m:
+ m.get(
+ self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?resolution=high&maxAge=10", content=response
+ )
+ single_view = TSC.ViewItem()
+ 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 +247,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 +288,231 @@ 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.ExcelRequestOptions(maxage=1)
+ self.server.views.populate_excel(single_view, request_option)
+
+ excel_file = b"".join(single_view.excel)
+ self.assertEqual(response, excel_file)
+
+ def test_filter_excel(self) -> None:
+ self.server.version = "3.8"
+ self.baseurl = self.server.views.baseurl
+ with open(POPULATE_EXCEL, "rb") as f:
+ response = f.read()
+ with requests_mock.mock() as m:
+ m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/crosstab/excel?maxAge=1", content=response)
+ single_view = TSC.ViewItem()
+ single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5"
+ request_option = TSC.ExcelRequestOptions(maxage=1)
+ request_option.vf("stuff", "1")
+ self.server.views.populate_excel(single_view, request_option)
+
+ excel_file = b"".join(single_view.excel)
+ self.assertEqual(response, excel_file)
+
+ def test_pdf_height(self) -> None:
+ self.server.version = "3.8"
+ self.baseurl = self.server.views.baseurl
+ with open(POPULATE_PDF, "rb") as f:
+ response = f.read()
+ with requests_mock.mock() as m:
+ m.get(
+ self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/pdf?vizHeight=1080&vizWidth=1920",
+ content=response,
+ )
+ single_view = TSC.ViewItem()
+ single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5"
+
+ req_option = TSC.PDFRequestOptions(
+ viz_height=1080,
+ viz_width=1920,
+ )
+
+ self.server.views.populate_pdf(single_view, req_option)
+ self.assertEqual(response, single_view.pdf)
+
+ def test_pdf_errors(self) -> None:
+ req_option = TSC.PDFRequestOptions(viz_height=1080)
+ with self.assertRaises(ValueError):
+ req_option.get_query_params()
+ req_option = TSC.PDFRequestOptions(viz_width=1920)
+ with self.assertRaises(ValueError):
+ req_option.get_query_params()
+
+ def test_view_get_all_fields(self) -> None:
+ self.server.version = "3.21"
+ self.baseurl = self.server.views.baseurl
+ with open(GET_XML_ALL_FIELDS) as f:
+ response_xml = f.read()
+
+ ro = TSC.RequestOptions()
+ ro.all_fields = True
+
+ with requests_mock.mock() as m:
+ m.get(f"{self.baseurl}?fields=_all_", text=response_xml)
+ views, _ = self.server.views.get(req_options=ro)
+
+ assert views[0].id == "2bdcd787-dcc6-4a5d-bc61-2846f1ef4534"
+ assert views[0].name == "Overview"
+ assert views[0].content_url == "Superstore/sheets/Overview"
+ assert views[0].created_at == parse_datetime("2024-02-14T04:42:09Z")
+ assert views[0].updated_at == parse_datetime("2024-02-14T04:42:09Z")
+ assert views[0].sheet_type == "dashboard"
+ assert views[0].favorites_total == 0
+ assert views[0].view_url_name == "Overview"
+ assert isinstance(views[0].workbook, TSC.WorkbookItem)
+ assert views[0].workbook.id == "9df3e2d1-070e-497a-9578-8cc557ced9df"
+ assert views[0].workbook.name == "Superstore"
+ assert views[0].workbook.content_url == "Superstore"
+ assert views[0].workbook.show_tabs
+ assert views[0].workbook.size == 2
+ assert views[0].workbook.created_at == parse_datetime("2024-02-14T04:42:09Z")
+ assert views[0].workbook.updated_at == parse_datetime("2024-02-14T04:42:10Z")
+ assert views[0].workbook.sheet_count == 9
+ assert not views[0].workbook.has_extracts
+ assert isinstance(views[0].owner, TSC.UserItem)
+ assert views[0].owner.email == "bob@example.com"
+ assert views[0].owner.fullname == "Bob"
+ assert views[0].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9"
+ assert views[0].owner.last_login == parse_datetime("2025-02-04T06:39:20Z")
+ assert views[0].owner.name == "bob@example.com"
+ assert views[0].owner.site_role == "SiteAdministratorCreator"
+ assert isinstance(views[0].project, TSC.ProjectItem)
+ assert views[0].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a"
+ assert views[0].project.name == "Samples"
+ assert views[0].project.description == "This project includes automatically uploaded samples."
+ assert views[0].total_views == 0
+ assert isinstance(views[0].location, TSC.LocationItem)
+ assert views[0].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a"
+ assert views[0].location.type == "Project"
+ assert views[1].id == "2a3fd19d-9129-413d-9ff7-9dfc36bf7f7e"
+ assert views[1].name == "Product"
+ assert views[1].content_url == "Superstore/sheets/Product"
+ assert views[1].created_at == parse_datetime("2024-02-14T04:42:09Z")
+ assert views[1].updated_at == parse_datetime("2024-02-14T04:42:09Z")
+ assert views[1].sheet_type == "dashboard"
+ assert views[1].favorites_total == 0
+ assert views[1].view_url_name == "Product"
+ assert isinstance(views[1].workbook, TSC.WorkbookItem)
+ assert views[1].workbook.id == "9df3e2d1-070e-497a-9578-8cc557ced9df"
+ assert views[1].workbook.name == "Superstore"
+ assert views[1].workbook.content_url == "Superstore"
+ assert views[1].workbook.show_tabs
+ assert views[1].workbook.size == 2
+ assert views[1].workbook.created_at == parse_datetime("2024-02-14T04:42:09Z")
+ assert views[1].workbook.updated_at == parse_datetime("2024-02-14T04:42:10Z")
+ assert views[1].workbook.sheet_count == 9
+ assert not views[1].workbook.has_extracts
+ assert isinstance(views[1].owner, TSC.UserItem)
+ assert views[1].owner.email == "bob@example.com"
+ assert views[1].owner.fullname == "Bob"
+ assert views[1].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9"
+ assert views[1].owner.last_login == parse_datetime("2025-02-04T06:39:20Z")
+ assert views[1].owner.name == "bob@example.com"
+ assert views[1].owner.site_role == "SiteAdministratorCreator"
+ assert isinstance(views[1].project, TSC.ProjectItem)
+ assert views[1].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a"
+ assert views[1].project.name == "Samples"
+ assert views[1].project.description == "This project includes automatically uploaded samples."
+ assert views[1].total_views == 0
+ assert isinstance(views[1].location, TSC.LocationItem)
+ assert views[1].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a"
+ assert views[1].location.type == "Project"
+ assert views[2].id == "459eda9a-85e4-46bf-a2f2-62936bd2e99a"
+ assert views[2].name == "Customers"
+ assert views[2].content_url == "Superstore/sheets/Customers"
+ assert views[2].created_at == parse_datetime("2024-02-14T04:42:09Z")
+ assert views[2].updated_at == parse_datetime("2024-02-14T04:42:09Z")
+ assert views[2].sheet_type == "dashboard"
+ assert views[2].favorites_total == 0
+ assert views[2].view_url_name == "Customers"
+ assert isinstance(views[2].workbook, TSC.WorkbookItem)
+ assert views[2].workbook.id == "9df3e2d1-070e-497a-9578-8cc557ced9df"
+ assert views[2].workbook.name == "Superstore"
+ assert views[2].workbook.content_url == "Superstore"
+ assert views[2].workbook.show_tabs
+ assert views[2].workbook.size == 2
+ assert views[2].workbook.created_at == parse_datetime("2024-02-14T04:42:09Z")
+ assert views[2].workbook.updated_at == parse_datetime("2024-02-14T04:42:10Z")
+ assert views[2].workbook.sheet_count == 9
+ assert not views[2].workbook.has_extracts
+ assert isinstance(views[2].owner, TSC.UserItem)
+ assert views[2].owner.email == "bob@example.com"
+ assert views[2].owner.fullname == "Bob"
+ assert views[2].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9"
+ assert views[2].owner.last_login == parse_datetime("2025-02-04T06:39:20Z")
+ assert views[2].owner.name == "bob@example.com"
+ assert views[2].owner.site_role == "SiteAdministratorCreator"
+ assert isinstance(views[2].project, TSC.ProjectItem)
+ assert views[2].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a"
+ assert views[2].project.name == "Samples"
+ assert views[2].project.description == "This project includes automatically uploaded samples."
+ assert views[2].total_views == 0
+ assert isinstance(views[2].location, TSC.LocationItem)
+ assert views[2].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a"
+ assert views[2].location.type == "Project"
diff --git a/test/test_view_acceleration.py b/test/test_view_acceleration.py
new file mode 100644
index 000000000..766831b0a
--- /dev/null
+++ b/test/test_view_acceleration.py
@@ -0,0 +1,119 @@
+import os
+import requests_mock
+import unittest
+
+import tableauserverclient as TSC
+from tableauserverclient.datetime_helpers import format_datetime
+
+TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets")
+
+GET_BY_ID_ACCELERATION_STATUS_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_by_id_acceleration_status.xml")
+POPULATE_VIEWS_XML = os.path.join(TEST_ASSET_DIR, "workbook_populate_views.xml")
+UPDATE_VIEWS_ACCELERATION_STATUS_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_views_acceleration_status.xml")
+UPDATE_WORKBOOK_ACCELERATION_STATUS_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_acceleration_status.xml")
+
+
+class WorkbookTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self.server = TSC.Server("http://test", False)
+
+ # Fake sign in
+ self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67"
+ self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM"
+
+ self.baseurl = self.server.workbooks.baseurl
+
+ def test_get_by_id(self) -> None:
+ with open(GET_BY_ID_ACCELERATION_STATUS_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+ with requests_mock.mock() as m:
+ m.get(self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42", text=response_xml)
+ single_workbook = self.server.workbooks.get_by_id("3cc6cd06-89ce-4fdc-b935-5294135d6d42")
+
+ self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", single_workbook.id)
+ self.assertEqual("SafariSample", single_workbook.name)
+ self.assertEqual("SafariSample", single_workbook.content_url)
+ self.assertEqual("http://tableauserver/#/workbooks/2/views", single_workbook.webpage_url)
+ self.assertEqual(False, single_workbook.show_tabs)
+ self.assertEqual(26, single_workbook.size)
+ self.assertEqual("2016-07-26T20:34:56Z", format_datetime(single_workbook.created_at))
+ self.assertEqual("description for SafariSample", single_workbook.description)
+ self.assertEqual("2016-07-26T20:35:05Z", format_datetime(single_workbook.updated_at))
+ self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", single_workbook.project_id)
+ self.assertEqual("default", single_workbook.project_name)
+ self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_workbook.owner_id)
+ self.assertEqual({"Safari", "Sample"}, single_workbook.tags)
+ self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", single_workbook.views[0].id)
+ self.assertEqual("ENDANGERED SAFARI", single_workbook.views[0].name)
+ self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", single_workbook.views[0].content_url)
+ self.assertEqual(True, single_workbook.views[0].data_acceleration_config["acceleration_enabled"])
+ self.assertEqual("Enabled", single_workbook.views[0].data_acceleration_config["acceleration_status"])
+ self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff9", single_workbook.views[1].id)
+ self.assertEqual("ENDANGERED SAFARI 2", single_workbook.views[1].name)
+ self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI2", single_workbook.views[1].content_url)
+ self.assertEqual(False, single_workbook.views[1].data_acceleration_config["acceleration_enabled"])
+ self.assertEqual("Suspended", single_workbook.views[1].data_acceleration_config["acceleration_status"])
+
+ def test_update_workbook_acceleration(self) -> None:
+ with open(UPDATE_WORKBOOK_ACCELERATION_STATUS_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+ with requests_mock.mock() as m:
+ m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml)
+ single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True)
+ single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2"
+ single_workbook.data_acceleration_config = {
+ "acceleration_enabled": True,
+ "accelerate_now": False,
+ "last_updated_at": None,
+ "acceleration_status": None,
+ }
+ # update with parameter includeViewAccelerationStatus=True
+ single_workbook = self.server.workbooks.update(single_workbook, True)
+
+ self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id)
+ self.assertEqual("1d0304cd-3796-429f-b815-7258370b9b74", single_workbook.project_id)
+ self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", single_workbook.views[0].content_url)
+ self.assertEqual(True, single_workbook.views[0].data_acceleration_config["acceleration_enabled"])
+ self.assertEqual("Pending", single_workbook.views[0].data_acceleration_config["acceleration_status"])
+ self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff9", single_workbook.views[1].id)
+ self.assertEqual("ENDANGERED SAFARI 2", single_workbook.views[1].name)
+ self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI2", single_workbook.views[1].content_url)
+ self.assertEqual(True, single_workbook.views[1].data_acceleration_config["acceleration_enabled"])
+ self.assertEqual("Pending", single_workbook.views[1].data_acceleration_config["acceleration_status"])
+
+ def test_update_views_acceleration(self) -> None:
+ with open(POPULATE_VIEWS_XML, "rb") as f:
+ views_xml = f.read().decode("utf-8")
+ with open(UPDATE_VIEWS_ACCELERATION_STATUS_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+ with requests_mock.mock() as m:
+ m.get(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/views", text=views_xml)
+ m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml)
+ single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True)
+ single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2"
+ single_workbook.data_acceleration_config = {
+ "acceleration_enabled": False,
+ "accelerate_now": False,
+ "last_updated_at": None,
+ "acceleration_status": None,
+ }
+ self.server.workbooks.populate_views(single_workbook)
+ single_workbook.views = [single_workbook.views[1], single_workbook.views[2]]
+ # update with parameter includeViewAccelerationStatus=True
+ single_workbook = self.server.workbooks.update(single_workbook, True)
+
+ views_list = single_workbook.views
+ self.assertEqual("097dbe13-de89-445f-b2c3-02f28bd010c1", views_list[0].id)
+ self.assertEqual("GDP per capita", views_list[0].name)
+ self.assertEqual(False, views_list[0].data_acceleration_config["acceleration_enabled"])
+ self.assertEqual("Disabled", views_list[0].data_acceleration_config["acceleration_status"])
+
+ self.assertEqual("2c1ab9d7-8d64-4cc6-b495-52e40c60c330", views_list[1].id)
+ self.assertEqual("Country ranks", views_list[1].name)
+ self.assertEqual(True, views_list[1].data_acceleration_config["acceleration_enabled"])
+ self.assertEqual("Pending", views_list[1].data_acceleration_config["acceleration_status"])
+
+ self.assertEqual("0599c28c-6d82-457e-a453-e52c1bdb00f5", views_list[2].id)
+ self.assertEqual("Interest rates", views_list[2].name)
+ self.assertEqual(True, views_list[2].data_acceleration_config["acceleration_enabled"])
+ self.assertEqual("Pending", views_list[2].data_acceleration_config["acceleration_status"])
diff --git a/test/test_virtual_connection.py b/test/test_virtual_connection.py
new file mode 100644
index 000000000..5d9a2d1bc
--- /dev/null
+++ b/test/test_virtual_connection.py
@@ -0,0 +1,248 @@
+import json
+from pathlib import Path
+import unittest
+
+import pytest
+import requests_mock
+
+import tableauserverclient as TSC
+from tableauserverclient.datetime_helpers import parse_datetime
+from tableauserverclient.models.virtual_connection_item import VirtualConnectionItem
+
+ASSET_DIR = Path(__file__).parent / "assets"
+
+VIRTUAL_CONNECTION_GET_XML = ASSET_DIR / "virtual_connections_get.xml"
+VIRTUAL_CONNECTION_POPULATE_CONNECTIONS = ASSET_DIR / "virtual_connection_populate_connections.xml"
+VIRTUAL_CONNECTION_POPULATE_CONNECTIONS2 = ASSET_DIR / "virtual_connection_populate_connections2.xml"
+VC_DB_CONN_UPDATE = ASSET_DIR / "virtual_connection_database_connection_update.xml"
+VIRTUAL_CONNECTION_DOWNLOAD = ASSET_DIR / "virtual_connections_download.xml"
+VIRTUAL_CONNECTION_UPDATE = ASSET_DIR / "virtual_connections_update.xml"
+VIRTUAL_CONNECTION_REVISIONS = ASSET_DIR / "virtual_connections_revisions.xml"
+VIRTUAL_CONNECTION_PUBLISH = ASSET_DIR / "virtual_connections_publish.xml"
+ADD_PERMISSIONS = ASSET_DIR / "virtual_connection_add_permissions.xml"
+
+
+class TestVirtualConnections(unittest.TestCase):
+ def setUp(self) -> None:
+ self.server = TSC.Server("http://test")
+
+ self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67"
+ self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM"
+ self.server.version = "3.23"
+
+ self.baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/virtualConnections"
+ return super().setUp()
+
+ def test_from_xml(self):
+ items = VirtualConnectionItem.from_response(VIRTUAL_CONNECTION_GET_XML.read_bytes(), self.server.namespace)
+
+ assert len(items) == 1
+ virtual_connection = items[0]
+ assert virtual_connection.created_at == parse_datetime("2024-05-30T09:00:00Z")
+ assert not virtual_connection.has_extracts
+ assert virtual_connection.id == "8fd7cc02-bb55-4d15-b8b1-9650239efe79"
+ assert virtual_connection.is_certified
+ assert virtual_connection.name == "vconn"
+ assert virtual_connection.updated_at == parse_datetime("2024-06-18T09:00:00Z")
+ assert virtual_connection.webpage_url == "https://test/#/site/site-name/virtualconnections/3"
+
+ def test_virtual_connection_get(self):
+ with requests_mock.mock() as m:
+ m.get(self.baseurl, text=VIRTUAL_CONNECTION_GET_XML.read_text())
+ items, pagination_item = self.server.virtual_connections.get()
+
+ assert len(items) == 1
+ assert pagination_item.total_available == 1
+ assert items[0].name == "vconn"
+
+ def test_virtual_connection_populate_connections(self):
+ for i, populate_connections_xml in enumerate(
+ (VIRTUAL_CONNECTION_POPULATE_CONNECTIONS, VIRTUAL_CONNECTION_POPULATE_CONNECTIONS2)
+ ):
+ with self.subTest(i):
+ vconn = VirtualConnectionItem("vconn")
+ vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79"
+ with requests_mock.mock() as m:
+ m.get(f"{self.baseurl}/{vconn.id}/connections", text=populate_connections_xml.read_text())
+ vc_out = self.server.virtual_connections.populate_connections(vconn)
+ connection_list = list(vconn.connections)
+
+ assert vc_out is vconn
+ assert vc_out._connections is not None
+
+ assert len(connection_list) == 1
+ connection = connection_list[0]
+ assert connection.id == "37ca6ced-58d7-4dcf-99dc-f0a85223cbef"
+ assert connection.connection_type == "postgres"
+ assert connection.server_address == "localhost"
+ assert connection.server_port == "5432"
+ assert connection.username == "pgadmin"
+
+ def test_virtual_connection_update_connection_db_connection(self):
+ vconn = VirtualConnectionItem("vconn")
+ vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79"
+ connection = TSC.ConnectionItem()
+ connection._id = "37ca6ced-58d7-4dcf-99dc-f0a85223cbef"
+ connection.server_address = "localhost"
+ connection.server_port = "5432"
+ connection.username = "pgadmin"
+ connection.password = "password"
+ with requests_mock.mock() as m:
+ m.put(f"{self.baseurl}/{vconn.id}/connections/{connection.id}/modify", text=VC_DB_CONN_UPDATE.read_text())
+ updated_connection = self.server.virtual_connections.update_connection_db_connection(vconn, connection)
+
+ assert updated_connection.id == "37ca6ced-58d7-4dcf-99dc-f0a85223cbef"
+ assert updated_connection.server_address == "localhost"
+ assert updated_connection.server_port == "5432"
+ assert updated_connection.username == "pgadmin"
+ assert updated_connection.password is None
+
+ def test_virtual_connection_get_by_id(self):
+ vconn = VirtualConnectionItem("vconn")
+ vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79"
+ with requests_mock.mock() as m:
+ m.get(f"{self.baseurl}/{vconn.id}", text=VIRTUAL_CONNECTION_DOWNLOAD.read_text())
+ vconn = self.server.virtual_connections.get_by_id(vconn)
+
+ assert vconn.content
+ assert vconn.created_at is None
+ assert vconn.id is None
+ assert "policyCollection" in vconn.content
+ assert "revision" in vconn.content
+
+ def test_virtual_connection_update(self):
+ vconn = VirtualConnectionItem("vconn")
+ vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79"
+ vconn.is_certified = True
+ vconn.certification_note = "demo certification note"
+ vconn.project_id = "5286d663-8668-4ac2-8c8d-91af7d585f6b"
+ vconn.owner_id = "9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0"
+ with requests_mock.mock() as m:
+ m.put(f"{self.baseurl}/{vconn.id}", text=VIRTUAL_CONNECTION_UPDATE.read_text())
+ vconn = self.server.virtual_connections.update(vconn)
+
+ assert not vconn.has_extracts
+ assert vconn.id is None
+ assert vconn.is_certified
+ assert vconn.name == "testv1"
+ assert vconn.certification_note == "demo certification note"
+ assert vconn.project_id == "5286d663-8668-4ac2-8c8d-91af7d585f6b"
+ assert vconn.owner_id == "9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0"
+
+ def test_virtual_connection_get_revisions(self):
+ vconn = VirtualConnectionItem("vconn")
+ vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79"
+ with requests_mock.mock() as m:
+ m.get(f"{self.baseurl}/{vconn.id}/revisions", text=VIRTUAL_CONNECTION_REVISIONS.read_text())
+ revisions, pagination_item = self.server.virtual_connections.get_revisions(vconn)
+
+ assert len(revisions) == 3
+ assert pagination_item.total_available == 3
+ assert revisions[0].resource_id == vconn.id
+ assert revisions[0].resource_name == vconn.name
+ assert revisions[0].created_at == parse_datetime("2016-07-26T20:34:56Z")
+ assert revisions[0].revision_number == "1"
+ assert not revisions[0].current
+ assert not revisions[0].deleted
+ assert revisions[0].user_name == "Cassie"
+ assert revisions[0].user_id == "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7"
+ assert revisions[1].resource_id == vconn.id
+ assert revisions[1].resource_name == vconn.name
+ assert revisions[1].created_at == parse_datetime("2016-07-27T20:34:56Z")
+ assert revisions[1].revision_number == "2"
+ assert not revisions[1].current
+ assert not revisions[1].deleted
+ assert revisions[2].resource_id == vconn.id
+ assert revisions[2].resource_name == vconn.name
+ assert revisions[2].created_at == parse_datetime("2016-07-28T20:34:56Z")
+ assert revisions[2].revision_number == "3"
+ assert revisions[2].current
+ assert not revisions[2].deleted
+ assert revisions[2].user_name == "Cassie"
+ assert revisions[2].user_id == "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7"
+
+ def test_virtual_connection_download_revision(self):
+ vconn = VirtualConnectionItem("vconn")
+ vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79"
+ with requests_mock.mock() as m:
+ m.get(f"{self.baseurl}/{vconn.id}/revisions/1", text=VIRTUAL_CONNECTION_DOWNLOAD.read_text())
+ content = self.server.virtual_connections.download_revision(vconn, 1)
+
+ assert content
+ assert "policyCollection" in content
+ data = json.loads(content)
+ assert "policyCollection" in data
+ assert "revision" in data
+
+ def test_virtual_connection_delete(self):
+ vconn = VirtualConnectionItem("vconn")
+ vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79"
+ with requests_mock.mock() as m:
+ m.delete(f"{self.baseurl}/{vconn.id}")
+ self.server.virtual_connections.delete(vconn)
+ self.server.virtual_connections.delete(vconn.id)
+
+ assert m.call_count == 2
+
+ def test_virtual_connection_publish(self):
+ vconn = VirtualConnectionItem("vconn")
+ vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79"
+ vconn.project_id = "9836791c-9468-40f0-b7f3-d10b9562a046"
+ vconn.owner_id = "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9"
+ with requests_mock.mock() as m:
+ m.post(f"{self.baseurl}?overwrite=false&publishAsDraft=false", text=VIRTUAL_CONNECTION_PUBLISH.read_text())
+ vconn = self.server.virtual_connections.publish(
+ vconn, '{"test": 0}', mode="CreateNew", publish_as_draft=False
+ )
+
+ assert vconn.name == "vconn_test"
+ assert vconn.owner_id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9"
+ assert vconn.project_id == "9836791c-9468-40f0-b7f3-d10b9562a046"
+ assert vconn.content
+ assert "policyCollection" in vconn.content
+ assert "revision" in vconn.content
+
+ def test_virtual_connection_publish_draft_overwrite(self):
+ vconn = VirtualConnectionItem("vconn")
+ vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79"
+ vconn.project_id = "9836791c-9468-40f0-b7f3-d10b9562a046"
+ vconn.owner_id = "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9"
+ with requests_mock.mock() as m:
+ m.post(f"{self.baseurl}?overwrite=true&publishAsDraft=true", text=VIRTUAL_CONNECTION_PUBLISH.read_text())
+ vconn = self.server.virtual_connections.publish(
+ vconn, '{"test": 0}', mode="Overwrite", publish_as_draft=True
+ )
+
+ assert vconn.name == "vconn_test"
+ assert vconn.owner_id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9"
+ assert vconn.project_id == "9836791c-9468-40f0-b7f3-d10b9562a046"
+ assert vconn.content
+ assert "policyCollection" in vconn.content
+ assert "revision" in vconn.content
+
+ def test_add_permissions(self) -> None:
+ with open(ADD_PERMISSIONS, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+
+ single_virtual_connection = TSC.VirtualConnectionItem("test")
+ single_virtual_connection._id = "21778de4-b7b9-44bc-a599-1506a2639ace"
+
+ bob = TSC.UserItem.as_reference("7c37ee24-c4b1-42b6-a154-eaeab7ee330a")
+ group_of_people = TSC.GroupItem.as_reference("5e5e1978-71fa-11e4-87dd-7382f5c437af")
+
+ new_permissions = [
+ TSC.PermissionsRule(bob, {"Write": "Allow"}),
+ TSC.PermissionsRule(group_of_people, {"Read": "Deny"}),
+ ]
+
+ with requests_mock.mock() as m:
+ m.put(self.baseurl + "/21778de4-b7b9-44bc-a599-1506a2639ace/permissions", text=response_xml)
+ permissions = self.server.virtual_connections.add_permissions(single_virtual_connection, new_permissions)
+
+ self.assertEqual(permissions[0].grantee.tag_name, "group")
+ self.assertEqual(permissions[0].grantee.id, "5e5e1978-71fa-11e4-87dd-7382f5c437af")
+ self.assertDictEqual(permissions[0].capabilities, {TSC.Permission.Capability.Read: TSC.Permission.Mode.Deny})
+
+ self.assertEqual(permissions[1].grantee.tag_name, "user")
+ self.assertEqual(permissions[1].grantee.id, "7c37ee24-c4b1-42b6-a154-eaeab7ee330a")
+ self.assertDictEqual(permissions[1].capabilities, {TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow})
diff --git a/test/test_webhook.py b/test/test_webhook.py
index 819de18ae..5f26266b2 100644
--- a/test/test_webhook.py
+++ b/test/test_webhook.py
@@ -1,32 +1,34 @@
-import unittest
import os
+import unittest
+
import requests_mock
-import tableauserverclient as TSC
-from tableauserverclient.server import RequestFactory, WebhookItem
-from ._utils import read_xml_asset, read_xml_assets, asset
+import tableauserverclient as TSC
+from tableauserverclient.server import RequestFactory
+from tableauserverclient.models import WebhookItem
+from ._utils import asset
-TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets')
+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 +41,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 +73,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..84afd7fcb 100644
--- a/test/test_workbook.py
+++ b/test/test_workbook.py
@@ -1,103 +1,107 @@
-import unittest
-from io import BytesIO
import os
import re
import requests_mock
-import tableauserverclient as TSC
-import xml.etree.ElementTree as ET
+import tempfile
+import unittest
+from defusedxml.ElementTree import fromstring
+from io import BytesIO
+from pathlib import Path
+import pytest
-from tableauserverclient.datetime_helpers import format_datetime
-from tableauserverclient.server.endpoint.exceptions import InternalServerError
+import tableauserverclient as TSC
+from tableauserverclient.datetime_helpers import format_datetime, parse_datetime
+from tableauserverclient.models import UserItem, GroupItem, PermissionsRule
+from tableauserverclient.server.endpoint.exceptions import InternalServerError, UnsupportedAttributeError
from tableauserverclient.server.request_factory import RequestFactory
-from tableauserverclient.models.permissions_item import PermissionsRule
-from tableauserverclient.models.user_item import UserItem
-from tableauserverclient.models.group_item import GroupItem
-
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")
+GET_XML_ALL_FIELDS = os.path.join(TEST_ASSET_DIR, "workbook_get_all_fields.xml")
+ODATA_XML = os.path.join(TEST_ASSET_DIR, "odata_connection.xml")
+POPULATE_CONNECTIONS_XML = os.path.join(TEST_ASSET_DIR, "workbook_populate_connections.xml")
+POPULATE_PDF = os.path.join(TEST_ASSET_DIR, "populate_pdf.pdf")
+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({"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 +109,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({"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({"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,504 +237,848 @@ 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_object(self) -> None:
+ with BytesIO() as file_object:
+ with requests_mock.mock() as m:
+ m.get(
+ self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content",
+ headers={"Content-Disposition": 'name="tableau_workbook"; filename="RESTAPISample.twbx"'},
+ )
+ file_path = self.server.workbooks.download("1f951daf-4061-451a-9df1-69a8062664f2", filepath=file_object)
+ self.assertTrue(isinstance(file_path, BytesIO))
+
+ def test_download_sanitizes_name(self) -> None:
filename = "Name,With,Commas.twbx"
- disposition = 'name="tableau_workbook"; filename="{}"'.format(filename)
+ disposition = f'name="tableau_workbook"; filename="{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"
+
+ type = TSC.PDFRequestOptions.PageType.A5
+ orientation = TSC.PDFRequestOptions.Orientation.Landscape
+ req_option = TSC.PDFRequestOptions(type, orientation)
+
+ self.server.workbooks.populate_pdf(single_workbook, req_option)
+ self.assertEqual(response, single_workbook.pdf)
+
+ def test_populate_pdf_unsupported(self) -> None:
self.server.version = "3.4"
self.baseurl = self.server.workbooks.baseurl
+ with requests_mock.mock() as m:
+ m.get(
+ self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/pdf?type=a5&orientation=landscape",
+ content=b"",
+ )
+ single_workbook = TSC.WorkbookItem("test")
+ single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2"
+
+ type = TSC.PDFRequestOptions.PageType.A5
+ orientation = TSC.PDFRequestOptions.Orientation.Landscape
+ req_option = TSC.PDFRequestOptions(type, orientation)
+ req_option.vf("Region", "West")
+
+ with self.assertRaises(UnsupportedAttributeError):
+ self.server.workbooks.populate_pdf(single_workbook, req_option)
+
+ def test_populate_pdf_vf_dims(self) -> None:
+ self.server.version = "3.23"
+ self.baseurl = self.server.workbooks.baseurl
with open(POPULATE_PDF, "rb") as f:
response = f.read()
with requests_mock.mock() as m:
- m.get(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/pdf?type=a5&orientation=landscape",
- 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&vf_Region=West&vizWidth=1920&vizHeight=1080",
+ content=response,
+ )
+ single_workbook = TSC.WorkbookItem("test")
+ single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2"
type = TSC.PDFRequestOptions.PageType.A5
orientation = TSC.PDFRequestOptions.Orientation.Landscape
req_option = TSC.PDFRequestOptions(type, orientation)
+ req_option.vf("Region", "West")
+ req_option.viz_width = 1920
+ req_option.viz_height = 1080
self.server.workbooks.populate_pdf(single_workbook, req_option)
self.assertEqual(response, single_workbook.pdf)
- def test_populate_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?maxAge=1",
+ content=response,
+ )
+ single_workbook = TSC.WorkbookItem("test")
+ single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2"
+
+ ro = TSC.PPTXRequestOptions(maxage=1)
+
+ self.server.workbooks.populate_powerpoint(single_workbook, ro)
+ self.assertEqual(response, single_workbook.powerpoint)
+
+ def test_populate_preview_image(self) -> None:
+ 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')
+ new_workbook.description = "REST API Testing"
+
+ 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)
+ self.assertEqual("REST API Testing", new_workbook.description)
+
+ 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')
-
- sample_workbook = os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx')
+ new_workbook = TSC.WorkbookItem(
+ name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760"
+ )
- with open(sample_workbook, 'rb') as fp:
+ sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx")
+ with open(sample_workbook, "rb") as fp:
publish_mode = self.server.PublishMode.CreateNew
- new_workbook = self.server.workbooks.publish(new_workbook,
- fp,
- publish_mode)
+ 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')
-
- sample_workbook = os.path.join(TEST_ASSET_DIR, 'RESTAPISample.twb')
+ new_workbook = TSC.WorkbookItem(
+ name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760"
+ )
- with open(sample_workbook, 'rb') as fp:
+ sample_workbook = os.path.join(TEST_ASSET_DIR, "RESTAPISample.twb")
+ with open(sample_workbook, "rb") as fp:
publish_mode = self.server.PublishMode.CreateNew
- new_workbook = self.server.workbooks.publish(new_workbook,
- fp,
- publish_mode)
+ 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))
+ 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_thumbnails_user_id(self) -> None:
+ with open(PUBLISH_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
with requests_mock.mock() as m:
m.post(self.baseurl, text=response_xml)
- new_workbook = TSC.WorkbookItem(name='Sample',
- show_tabs=False,
- project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760')
+ new_workbook = TSC.WorkbookItem(
+ name="Sample",
+ show_tabs=False,
+ project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760",
+ thumbnails_user_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20761",
+ )
- sample_workbook = os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx')
+ sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx")
publish_mode = self.server.PublishMode.CreateNew
+ new_workbook = self.server.workbooks.publish(new_workbook, sample_workbook, publish_mode)
+ request_body = m._adapter.request_history[0]._request.body
+ # order of attributes in xml is unspecified
+ self.assertTrue(re.search(rb"thumbnailsUserId=\"ee8c6e70-43b6-11e6-af4f-f7b0d8e20761\"", request_body))
- self.server.workbooks.publish(new_workbook, sample_workbook, publish_mode,
- as_job=True, skip_connection_check=True)
+ def test_publish_with_thumbnails_group_id(self) -> None:
+ with open(PUBLISH_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+ with requests_mock.mock() as m:
+ m.post(self.baseurl, text=response_xml)
- 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'])
+ new_workbook = TSC.WorkbookItem(
+ name="Sample",
+ show_tabs=False,
+ project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760",
+ thumbnails_group_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20762",
+ )
- def test_publish_async(self):
- 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')
+ sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx")
+ publish_mode = self.server.PublishMode.CreateNew
+ new_workbook = self.server.workbooks.publish(new_workbook, sample_workbook, publish_mode)
+ request_body = m._adapter.request_history[0]._request.body
+ self.assertTrue(re.search(rb"thumbnailsGroupId=\"ee8c6e70-43b6-11e6-af4f-f7b0d8e20762\"", request_body))
+
+ @pytest.mark.filterwarnings("ignore:'as_job' not available")
+ def test_publish_with_query_params(self) -> None:
+ with open(PUBLISH_ASYNC_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
with requests_mock.mock() as m:
- m.post(baseurl, text=response_xml)
+ 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_job = self.server.workbooks.publish(new_workbook,
- sample_workbook,
- publish_mode,
- as_job=True)
+ self.server.workbooks.publish(
+ new_workbook, sample_workbook, publish_mode, as_job=True, skip_connection_check=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(1, new_job.finish_code)
+ 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"])
- 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_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 requests_mock.mock() as m:
+ m.post(baseurl, text=response_xml)
- 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)
+ new_workbook = TSC.WorkbookItem(
+ name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760"
+ )
- def test_publish_unnamed_file_object(self):
- new_workbook = TSC.WorkbookItem('test')
+ sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx")
+ publish_mode = self.server.PublishMode.CreateNew
- with open(os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx')) as f:
+ new_job = self.server.workbooks.publish(new_workbook, sample_workbook, publish_mode, as_job=True)
- self.assertRaises(ValueError, self.server.workbooks.publish,
- new_workbook, f, self.server.PublishMode.CreateNew
- )
+ 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_file_object_of_unknown_type_raises_exception(self):
- new_workbook = TSC.WorkbookItem('test')
+ 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) -> 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) -> None:
+ new_workbook = TSC.WorkbookItem("test")
+
+ with open(os.path.join(TEST_ASSET_DIR, "SampleWB.twbx"), "rb") as f:
+ self.assertRaises(
+ ValueError, self.server.workbooks.publish, new_workbook, f, self.server.PublishMode.CreateNew
+ )
+
+ 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_multi_connection_flat(self) -> None:
+ new_workbook = TSC.WorkbookItem(
+ name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760"
+ )
+ connection1 = TSC.ConnectionItem()
+ connection1.server_address = "mysql.test.com"
+ connection1.username = "test"
+ connection1.password = "secret"
+ connection1.embed_password = True
+ connection2 = TSC.ConnectionItem()
+ connection2.server_address = "pgsql.test.com"
+ connection2.username = "test"
+ connection2.password = "secret"
+ connection2.embed_password = True
- response = RequestFactory.Workbook._generate_xml(new_workbook, connection_credentials=connection_creds)
+ response = RequestFactory.Workbook._generate_xml(new_workbook, connections=[connection1, connection2])
# Can't use ConnectionItem parser due to xml namespace problems
- credentials = ET.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')
+ connection_results = fromstring(response).findall(".//connection")
- 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')
-
- connection_creds = TSC.ConnectionCredentials('test', 'secret', True)
-
- connection1 = TSC.ConnectionItem()
- connection1.server_address = 'mysql.test.com'
- connection1.connection_credentials = TSC.ConnectionCredentials('test', 'secret', True)
+ 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]
- with self.assertRaises(RuntimeError):
- 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 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)
+
+ 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(f"{self.baseurl}/{workbook.id}/revisions", 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(f"{self.baseurl}/{workbook.id}/revisions/3")
+ 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))
+
+ def test_bad_download_response(self) -> None:
+ with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td:
+ m.get(
+ self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content",
+ headers={"Content-Disposition": '''name="tableau_workbook"; filename*=UTF-8''"Sample workbook.twb"'''},
+ )
+ file_path = self.server.workbooks.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", td)
+ self.assertTrue(os.path.exists(file_path))
+
+ def test_odata_connection(self) -> None:
+ self.baseurl = self.server.workbooks.baseurl
+ workbook = TSC.WorkbookItem("project", "test")
+ workbook._id = "06b944d2-959d-4604-9305-12323c95e70e"
+ connection = TSC.ConnectionItem()
+ url = "https://odata.website.com/TestODataEndpoint"
+ connection.server_address = url
+ connection._connection_type = "odata"
+ connection._id = "17376070-64d1-4d17-acb4-a56e4b5b1768"
+
+ creds = TSC.ConnectionCredentials("", "", True)
+ connection.connection_credentials = creds
+ with open(ODATA_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+
+ with requests_mock.mock() as m:
+ m.put(f"{self.baseurl}/{workbook.id}/connections/{connection.id}", text=response_xml)
+ self.server.workbooks.update_connection(workbook, connection)
+
+ history = m.request_history
+
+ request = history[0]
+ xml = fromstring(request.body)
+ xml_connection = xml.find(".//connection")
+
+ assert xml_connection is not None
+ self.assertEqual(xml_connection.get("serverAddress"), url)
+
+ def test_get_workbook_all_fields(self) -> None:
+ self.server.version = "3.21"
+ baseurl = self.server.workbooks.baseurl
+
+ with open(GET_XML_ALL_FIELDS) as f:
+ response = f.read()
+
+ ro = TSC.RequestOptions()
+ ro.all_fields = True
- with 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.get(f"{baseurl}?fields=_all_", text=response)
+ workbooks, _ = self.server.workbooks.get(req_options=ro)
+
+ assert workbooks[0].id == "9df3e2d1-070e-497a-9578-8cc557ced9df"
+ assert workbooks[0].name == "Superstore"
+ assert workbooks[0].content_url == "Superstore"
+ assert workbooks[0].webpage_url == "https://10ax.online.tableau.com/#/site/exampledev/workbooks/265605"
+ assert workbooks[0].show_tabs
+ assert workbooks[0].size == 2
+ assert workbooks[0].created_at == parse_datetime("2024-02-14T04:42:09Z")
+ assert workbooks[0].updated_at == parse_datetime("2024-02-14T04:42:10Z")
+ assert workbooks[0].sheet_count == 9
+ assert not workbooks[0].has_extracts
+ assert not workbooks[0].encrypt_extracts
+ assert workbooks[0].default_view_id == "2bdcd787-dcc6-4a5d-bc61-2846f1ef4534"
+ assert workbooks[0].share_description == "Superstore"
+ assert workbooks[0].last_published_at == parse_datetime("2024-02-14T04:42:09Z")
+ assert isinstance(workbooks[0].project, TSC.ProjectItem)
+ assert workbooks[0].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a"
+ assert workbooks[0].project.name == "Samples"
+ assert workbooks[0].project.description == "This project includes automatically uploaded samples."
+ assert isinstance(workbooks[0].location, TSC.LocationItem)
+ assert workbooks[0].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a"
+ assert workbooks[0].location.type == "Project"
+ assert workbooks[0].location.name == "Samples"
+ assert isinstance(workbooks[0].owner, TSC.UserItem)
+ assert workbooks[0].owner.email == "bob@example.com"
+ assert workbooks[0].owner.fullname == "Bob Smith"
+ assert workbooks[0].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9"
+ assert workbooks[0].owner.last_login == parse_datetime("2025-02-04T06:39:20Z")
+ assert workbooks[0].owner.name == "bob@example.com"
+ assert workbooks[0].owner.site_role == "SiteAdministratorCreator"
+ assert workbooks[1].id == "6693cb26-9507-4174-ad3e-9de81a18c971"
+ assert workbooks[1].name == "World Indicators"
+ assert workbooks[1].content_url == "WorldIndicators"
+ assert workbooks[1].webpage_url == "https://10ax.online.tableau.com/#/site/exampledev/workbooks/265606"
+ assert workbooks[1].show_tabs
+ assert workbooks[1].size == 1
+ assert workbooks[1].created_at == parse_datetime("2024-02-14T04:42:11Z")
+ assert workbooks[1].updated_at == parse_datetime("2024-02-14T04:42:12Z")
+ assert workbooks[1].sheet_count == 8
+ assert not workbooks[1].has_extracts
+ assert not workbooks[1].encrypt_extracts
+ assert workbooks[1].default_view_id == "3d10dbcf-a206-47c7-91ba-ebab3ab33d7c"
+ assert workbooks[1].share_description == "World Indicators"
+ assert workbooks[1].last_published_at == parse_datetime("2024-02-14T04:42:11Z")
+ assert isinstance(workbooks[1].project, TSC.ProjectItem)
+ assert workbooks[1].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a"
+ assert workbooks[1].project.name == "Samples"
+ assert workbooks[1].project.description == "This project includes automatically uploaded samples."
+ assert isinstance(workbooks[1].location, TSC.LocationItem)
+ assert workbooks[1].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a"
+ assert workbooks[1].location.type == "Project"
+ assert workbooks[1].location.name == "Samples"
+ assert isinstance(workbooks[1].owner, TSC.UserItem)
+ assert workbooks[1].owner.email == "bob@example.com"
+ assert workbooks[1].owner.fullname == "Bob Smith"
+ assert workbooks[1].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9"
+ assert workbooks[1].owner.last_login == parse_datetime("2025-02-04T06:39:20Z")
+ assert workbooks[1].owner.name == "bob@example.com"
+ assert workbooks[1].owner.site_role == "SiteAdministratorCreator"
+ assert workbooks[2].id == "dbc0f162-909f-4edf-8392-0d12a80af955"
+ assert workbooks[2].name == "Superstore"
+ assert workbooks[2].description == "This is a superstore workbook"
+ assert workbooks[2].content_url == "Superstore_17078880698360"
+ assert workbooks[2].webpage_url == "https://10ax.online.tableau.com/#/site/exampledev/workbooks/265621"
+ assert not workbooks[2].show_tabs
+ assert workbooks[2].size == 1
+ assert workbooks[2].created_at == parse_datetime("2024-02-14T05:21:09Z")
+ assert workbooks[2].updated_at == parse_datetime("2024-07-02T02:19:59Z")
+ assert workbooks[2].sheet_count == 7
+ assert workbooks[2].has_extracts
+ assert not workbooks[2].encrypt_extracts
+ assert workbooks[2].default_view_id == "8c4b1d3e-3f31-4d2a-8b9f-492b92f27987"
+ assert workbooks[2].share_description == "Superstore"
+ assert workbooks[2].last_published_at == parse_datetime("2024-07-02T02:19:58Z")
+ assert isinstance(workbooks[2].project, TSC.ProjectItem)
+ assert workbooks[2].project.id == "9836791c-9468-40f0-b7f3-d10b9562a046"
+ assert workbooks[2].project.name == "default"
+ assert workbooks[2].project.description == "The default project that was automatically created by Tableau."
+ assert isinstance(workbooks[2].location, TSC.LocationItem)
+ assert workbooks[2].location.id == "9836791c-9468-40f0-b7f3-d10b9562a046"
+ assert workbooks[2].location.type == "Project"
+ assert workbooks[2].location.name == "default"
+ assert isinstance(workbooks[2].owner, TSC.UserItem)
+ assert workbooks[2].owner.email == "bob@example.com"
+ assert workbooks[2].owner.fullname == "Bob Smith"
+ assert workbooks[2].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9"
+ assert workbooks[2].owner.last_login == parse_datetime("2025-02-04T06:39:20Z")
+ assert workbooks[2].owner.name == "bob@example.com"
+ assert workbooks[2].owner.site_role == "SiteAdministratorCreator"
diff --git a/test/test_workbook_model.py b/test/test_workbook_model.py
index 69188fa4a..fc6423564 100644
--- a/test/test_workbook_model.py
+++ b/test/test_workbook_model.py
@@ -1,14 +1,9 @@
import unittest
+
import tableauserverclient as TSC
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):
diff --git a/versioneer.py b/versioneer.py
deleted file mode 100755
index 59211ed6f..000000000
--- a/versioneer.py
+++ /dev/null
@@ -1,1822 +0,0 @@
-#!/usr/bin/env python
-# Version: 0.18
-
-"""The Versioneer - like a rocketeer, but for versions.
-
-The Versioneer
-==============
-
-* like a rocketeer, but for versions!
-* https://github.com/warner/python-versioneer
-* Brian Warner
-* License: Public Domain
-* Compatible With: python2.6, 2.7, 3.2, 3.3, 3.4, 3.5, 3.6, and pypy
-* [![Latest Version]
-(https://pypip.in/version/versioneer/badge.svg?style=flat)
-](https://pypi.python.org/pypi/versioneer/)
-* [![Build Status]
-(https://travis-ci.org/warner/python-versioneer.png?branch=master)
-](https://travis-ci.org/warner/python-versioneer)
-
-This is a tool for managing a recorded version number in distutils-based
-python projects. The goal is to remove the tedious and error-prone "update
-the embedded version string" step from your release process. Making a new
-release should be as easy as recording a new tag in your version-control
-system, and maybe making new tarballs.
-
-
-## Quick Install
-
-* `pip install versioneer` to somewhere to your $PATH
-* add a `[versioneer]` section to your setup.cfg (see below)
-* run `versioneer install` in your source tree, commit the results
-
-## Version Identifiers
-
-Source trees come from a variety of places:
-
-* a version-control system checkout (mostly used by developers)
-* a nightly tarball, produced by build automation
-* a snapshot tarball, produced by a web-based VCS browser, like github's
- "tarball from tag" feature
-* a release tarball, produced by "setup.py sdist", distributed through PyPI
-
-Within each source tree, the version identifier (either a string or a number,
-this tool is format-agnostic) can come from a variety of places:
-
-* ask the VCS tool itself, e.g. "git describe" (for checkouts), which knows
- about recent "tags" and an absolute revision-id
-* the name of the directory into which the tarball was unpacked
-* an expanded VCS keyword ($Id$, etc)
-* a `_version.py` created by some earlier build step
-
-For released software, the version identifier is closely related to a VCS
-tag. Some projects use tag names that include more than just the version
-string (e.g. "myproject-1.2" instead of just "1.2"), in which case the tool
-needs to strip the tag prefix to extract the version identifier. For
-unreleased software (between tags), the version identifier should provide
-enough information to help developers recreate the same tree, while also
-giving them an idea of roughly how old the tree is (after version 1.2, before
-version 1.3). Many VCS systems can report a description that captures this,
-for example `git describe --tags --dirty --always` reports things like
-"0.7-1-g574ab98-dirty" to indicate that the checkout is one revision past the
-0.7 tag, has a unique revision id of "574ab98", and is "dirty" (it has
-uncommitted changes.
-
-The version identifier is used for multiple purposes:
-
-* to allow the module to self-identify its version: `myproject.__version__`
-* to choose a name and prefix for a 'setup.py sdist' tarball
-
-## Theory of Operation
-
-Versioneer works by adding a special `_version.py` file into your source
-tree, where your `__init__.py` can import it. This `_version.py` knows how to
-dynamically ask the VCS tool for version information at import time.
-
-`_version.py` also contains `$Revision$` markers, and the installation
-process marks `_version.py` to have this marker rewritten with a tag name
-during the `git archive` command. As a result, generated tarballs will
-contain enough information to get the proper version.
-
-To allow `setup.py` to compute a version too, a `versioneer.py` is added to
-the top level of your source tree, next to `setup.py` and the `setup.cfg`
-that configures it. This overrides several distutils/setuptools commands to
-compute the version when invoked, and changes `setup.py build` and `setup.py
-sdist` to replace `_version.py` with a small static file that contains just
-the generated version data.
-
-## Installation
-
-See [INSTALL.md](./INSTALL.md) for detailed installation instructions.
-
-## Version-String Flavors
-
-Code which uses Versioneer can learn about its version string at runtime by
-importing `_version` from your main `__init__.py` file and running the
-`get_versions()` function. From the "outside" (e.g. in `setup.py`), you can
-import the top-level `versioneer.py` and run `get_versions()`.
-
-Both functions return a dictionary with different flavors of version
-information:
-
-* `['version']`: A condensed version string, rendered using the selected
- style. This is the most commonly used value for the project's version
- string. The default "pep440" style yields strings like `0.11`,
- `0.11+2.g1076c97`, or `0.11+2.g1076c97.dirty`. See the "Styles" section
- below for alternative styles.
-
-* `['full-revisionid']`: detailed revision identifier. For Git, this is the
- full SHA1 commit id, e.g. "1076c978a8d3cfc70f408fe5974aa6c092c949ac".
-
-* `['date']`: Date and time of the latest `HEAD` commit. For Git, it is the
- commit date in ISO 8601 format. This will be None if the date is not
- available.
-
-* `['dirty']`: a boolean, True if the tree has uncommitted changes. Note that
- this is only accurate if run in a VCS checkout, otherwise it is likely to
- be False or None
-
-* `['error']`: if the version string could not be computed, this will be set
- to a string describing the problem, otherwise it will be None. It may be
- useful to throw an exception in setup.py if this is set, to avoid e.g.
- creating tarballs with a version string of "unknown".
-
-Some variants are more useful than others. Including `full-revisionid` in a
-bug report should allow developers to reconstruct the exact code being tested
-(or indicate the presence of local changes that should be shared with the
-developers). `version` is suitable for display in an "about" box or a CLI
-`--version` output: it can be easily compared against release notes and lists
-of bugs fixed in various releases.
-
-The installer adds the following text to your `__init__.py` to place a basic
-version in `YOURPROJECT.__version__`:
-
- from ._version import get_versions
- __version__ = get_versions()['version']
- del get_versions
-
-## Styles
-
-The setup.cfg `style=` configuration controls how the VCS information is
-rendered into a version string.
-
-The default style, "pep440", produces a PEP440-compliant string, equal to the
-un-prefixed tag name for actual releases, and containing an additional "local
-version" section with more detail for in-between builds. For Git, this is
-TAG[+DISTANCE.gHEX[.dirty]] , using information from `git describe --tags
---dirty --always`. For example "0.11+2.g1076c97.dirty" indicates that the
-tree is like the "1076c97" commit but has uncommitted changes (".dirty"), and
-that this commit is two revisions ("+2") beyond the "0.11" tag. For released
-software (exactly equal to a known tag), the identifier will only contain the
-stripped tag, e.g. "0.11".
-
-Other styles are available. See [details.md](details.md) in the Versioneer
-source tree for descriptions.
-
-## Debugging
-
-Versioneer tries to avoid fatal errors: if something goes wrong, it will tend
-to return a version of "0+unknown". To investigate the problem, run `setup.py
-version`, which will run the version-lookup code in a verbose mode, and will
-display the full contents of `get_versions()` (including the `error` string,
-which may help identify what went wrong).
-
-## Known Limitations
-
-Some situations are known to cause problems for Versioneer. This details the
-most significant ones. More can be found on Github
-[issues page](https://github.com/warner/python-versioneer/issues).
-
-### Subprojects
-
-Versioneer has limited support for source trees in which `setup.py` is not in
-the root directory (e.g. `setup.py` and `.git/` are *not* siblings). The are
-two common reasons why `setup.py` might not be in the root:
-
-* Source trees which contain multiple subprojects, such as
- [Buildbot](https://github.com/buildbot/buildbot), which contains both
- "master" and "slave" subprojects, each with their own `setup.py`,
- `setup.cfg`, and `tox.ini`. Projects like these produce multiple PyPI
- distributions (and upload multiple independently-installable tarballs).
-* Source trees whose main purpose is to contain a C library, but which also
- provide bindings to Python (and perhaps other langauges) in subdirectories.
-
-Versioneer will look for `.git` in parent directories, and most operations
-should get the right version string. However `pip` and `setuptools` have bugs
-and implementation details which frequently cause `pip install .` from a
-subproject directory to fail to find a correct version string (so it usually
-defaults to `0+unknown`).
-
-`pip install --editable .` should work correctly. `setup.py install` might
-work too.
-
-Pip-8.1.1 is known to have this problem, but hopefully it will get fixed in
-some later version.
-
-[Bug #38](https://github.com/warner/python-versioneer/issues/38) is tracking
-this issue. The discussion in
-[PR #61](https://github.com/warner/python-versioneer/pull/61) describes the
-issue from the Versioneer side in more detail.
-[pip PR#3176](https://github.com/pypa/pip/pull/3176) and
-[pip PR#3615](https://github.com/pypa/pip/pull/3615) contain work to improve
-pip to let Versioneer work correctly.
-
-Versioneer-0.16 and earlier only looked for a `.git` directory next to the
-`setup.cfg`, so subprojects were completely unsupported with those releases.
-
-### Editable installs with setuptools <= 18.5
-
-`setup.py develop` and `pip install --editable .` allow you to install a
-project into a virtualenv once, then continue editing the source code (and
-test) without re-installing after every change.
-
-"Entry-point scripts" (`setup(entry_points={"console_scripts": ..})`) are a
-convenient way to specify executable scripts that should be installed along
-with the python package.
-
-These both work as expected when using modern setuptools. When using
-setuptools-18.5 or earlier, however, certain operations will cause
-`pkg_resources.DistributionNotFound` errors when running the entrypoint
-script, which must be resolved by re-installing the package. This happens
-when the install happens with one version, then the egg_info data is
-regenerated while a different version is checked out. Many setup.py commands
-cause egg_info to be rebuilt (including `sdist`, `wheel`, and installing into
-a different virtualenv), so this can be surprising.
-
-[Bug #83](https://github.com/warner/python-versioneer/issues/83) describes
-this one, but upgrading to a newer version of setuptools should probably
-resolve it.
-
-### Unicode version strings
-
-While Versioneer works (and is continually tested) with both Python 2 and
-Python 3, it is not entirely consistent with bytes-vs-unicode distinctions.
-Newer releases probably generate unicode version strings on py2. It's not
-clear that this is wrong, but it may be surprising for applications when then
-write these strings to a network connection or include them in bytes-oriented
-APIs like cryptographic checksums.
-
-[Bug #71](https://github.com/warner/python-versioneer/issues/71) investigates
-this question.
-
-
-## Updating Versioneer
-
-To upgrade your project to a new release of Versioneer, do the following:
-
-* install the new Versioneer (`pip install -U versioneer` or equivalent)
-* edit `setup.cfg`, if necessary, to include any new configuration settings
- indicated by the release notes. See [UPGRADING](./UPGRADING.md) for details.
-* re-run `versioneer install` in your source tree, to replace
- `SRC/_version.py`
-* commit any changed files
-
-## Future Directions
-
-This tool is designed to make it easily extended to other version-control
-systems: all VCS-specific components are in separate directories like
-src/git/ . The top-level `versioneer.py` script is assembled from these
-components by running make-versioneer.py . In the future, make-versioneer.py
-will take a VCS name as an argument, and will construct a version of
-`versioneer.py` that is specific to the given VCS. It might also take the
-configuration arguments that are currently provided manually during
-installation by editing setup.py . Alternatively, it might go the other
-direction and include code from all supported VCS systems, reducing the
-number of intermediate scripts.
-
-
-## License
-
-To make Versioneer easier to embed, all its code is dedicated to the public
-domain. The `_version.py` that it creates is also in the public domain.
-Specifically, both are released under the Creative Commons "Public Domain
-Dedication" license (CC0-1.0), as described in
-https://creativecommons.org/publicdomain/zero/1.0/ .
-
-"""
-
-from __future__ import print_function
-try:
- import configparser
-except ImportError:
- import ConfigParser as configparser
-import errno
-import json
-import os
-import re
-import subprocess
-import sys
-
-
-class VersioneerConfig:
- """Container for Versioneer configuration parameters."""
-
-
-def get_root():
- """Get the project root directory.
-
- We require that all commands are run from the project root, i.e. the
- directory that contains setup.py, setup.cfg, and versioneer.py .
- """
- root = os.path.realpath(os.path.abspath(os.getcwd()))
- setup_py = os.path.join(root, "setup.py")
- versioneer_py = os.path.join(root, "versioneer.py")
- if not (os.path.exists(setup_py) or os.path.exists(versioneer_py)):
- # allow 'python path/to/setup.py COMMAND'
- root = os.path.dirname(os.path.realpath(os.path.abspath(sys.argv[0])))
- setup_py = os.path.join(root, "setup.py")
- versioneer_py = os.path.join(root, "versioneer.py")
- if not (os.path.exists(setup_py) or os.path.exists(versioneer_py)):
- err = ("Versioneer was unable to run the project root directory. "
- "Versioneer requires setup.py to be executed from "
- "its immediate directory (like 'python setup.py COMMAND'), "
- "or in a way that lets it use sys.argv[0] to find the root "
- "(like 'python path/to/setup.py COMMAND').")
- raise VersioneerBadRootError(err)
- try:
- # Certain runtime workflows (setup.py install/develop in a setuptools
- # tree) execute all dependencies in a single python process, so
- # "versioneer" may be imported multiple times, and python's shared
- # module-import table will cache the first one. So we can't use
- # os.path.dirname(__file__), as that will find whichever
- # versioneer.py was first imported, even in later projects.
- me = os.path.realpath(os.path.abspath(__file__))
- me_dir = os.path.normcase(os.path.splitext(me)[0])
- vsr_dir = os.path.normcase(os.path.splitext(versioneer_py)[0])
- if me_dir != vsr_dir:
- print("Warning: build in %s is using versioneer.py from %s"
- % (os.path.dirname(me), versioneer_py))
- except NameError:
- pass
- return root
-
-
-def get_config_from_root(root):
- """Read the project setup.cfg file to determine Versioneer config."""
- # This might raise EnvironmentError (if setup.cfg is missing), or
- # configparser.NoSectionError (if it lacks a [versioneer] section), or
- # configparser.NoOptionError (if it lacks "VCS="). See the docstring at
- # the top of versioneer.py for instructions on writing your setup.cfg .
- setup_cfg = os.path.join(root, "setup.cfg")
- parser = configparser.SafeConfigParser()
- with open(setup_cfg, "r") as f:
- parser.readfp(f)
- VCS = parser.get("versioneer", "VCS") # mandatory
-
- def get(parser, name):
- if parser.has_option("versioneer", name):
- return parser.get("versioneer", name)
- return None
- cfg = VersioneerConfig()
- cfg.VCS = VCS
- cfg.style = get(parser, "style") or ""
- cfg.versionfile_source = get(parser, "versionfile_source")
- cfg.versionfile_build = get(parser, "versionfile_build")
- cfg.tag_prefix = get(parser, "tag_prefix")
- if cfg.tag_prefix in ("''", '""'):
- cfg.tag_prefix = ""
- cfg.parentdir_prefix = get(parser, "parentdir_prefix")
- cfg.verbose = get(parser, "verbose")
- return cfg
-
-
-class NotThisMethod(Exception):
- """Exception raised if a method is not valid for the current scenario."""
-
-
-# these dictionaries contain VCS-specific tools
-LONG_VERSION_PY = {}
-HANDLERS = {}
-
-
-def register_vcs_handler(vcs, method): # decorator
- """Decorator to mark a method as the handler for a particular VCS."""
- def decorate(f):
- """Store f in HANDLERS[vcs][method]."""
- if vcs not in HANDLERS:
- HANDLERS[vcs] = {}
- HANDLERS[vcs][method] = f
- return f
- return decorate
-
-
-def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False,
- env=None):
- """Call the given command(s)."""
- assert isinstance(commands, list)
- p = None
- for c in commands:
- try:
- dispcmd = str([c] + args)
- # remember shell=False, so use git.cmd on windows, not just git
- p = subprocess.Popen([c] + args, cwd=cwd, env=env,
- stdout=subprocess.PIPE,
- stderr=(subprocess.PIPE if hide_stderr
- else None))
- break
- except EnvironmentError:
- e = sys.exc_info()[1]
- if e.errno == errno.ENOENT:
- continue
- if verbose:
- print("unable to run %s" % dispcmd)
- print(e)
- return None, None
- else:
- if verbose:
- print("unable to find command, tried %s" % (commands,))
- return None, None
- stdout = p.communicate()[0].strip()
- if sys.version_info[0] >= 3:
- stdout = stdout.decode()
- if p.returncode != 0:
- if verbose:
- print("unable to run %s (error)" % dispcmd)
- print("stdout was %s" % stdout)
- return None, p.returncode
- return stdout, p.returncode
-
-
-LONG_VERSION_PY['git'] = '''
-# This file helps to compute a version number in source trees obtained from
-# git-archive tarball (such as those provided by githubs download-from-tag
-# feature). Distribution tarballs (built by setup.py sdist) and build
-# directories (produced by setup.py build) will contain a much shorter file
-# that just contains the computed version number.
-
-# This file is released into the public domain. Generated by
-# versioneer-0.18 (https://github.com/warner/python-versioneer)
-
-"""Git implementation of _version.py."""
-
-import errno
-import os
-import re
-import subprocess
-import sys
-
-
-def get_keywords():
- """Get the keywords needed to look up the version information."""
- # these strings will be replaced by git during git-archive.
- # setup.py/versioneer.py will grep for the variable names, so they must
- # each be defined on a line of their own. _version.py will just call
- # get_keywords().
- git_refnames = "%(DOLLAR)sFormat:%%d%(DOLLAR)s"
- git_full = "%(DOLLAR)sFormat:%%H%(DOLLAR)s"
- git_date = "%(DOLLAR)sFormat:%%ci%(DOLLAR)s"
- keywords = {"refnames": git_refnames, "full": git_full, "date": git_date}
- return keywords
-
-
-class VersioneerConfig:
- """Container for Versioneer configuration parameters."""
-
-
-def get_config():
- """Create, populate and return the VersioneerConfig() object."""
- # these strings are filled in when 'setup.py versioneer' creates
- # _version.py
- cfg = VersioneerConfig()
- cfg.VCS = "git"
- cfg.style = "%(STYLE)s"
- cfg.tag_prefix = "%(TAG_PREFIX)s"
- cfg.parentdir_prefix = "%(PARENTDIR_PREFIX)s"
- cfg.versionfile_source = "%(VERSIONFILE_SOURCE)s"
- cfg.verbose = False
- return cfg
-
-
-class NotThisMethod(Exception):
- """Exception raised if a method is not valid for the current scenario."""
-
-
-LONG_VERSION_PY = {}
-HANDLERS = {}
-
-
-def register_vcs_handler(vcs, method): # decorator
- """Decorator to mark a method as the handler for a particular VCS."""
- def decorate(f):
- """Store f in HANDLERS[vcs][method]."""
- if vcs not in HANDLERS:
- HANDLERS[vcs] = {}
- HANDLERS[vcs][method] = f
- return f
- return decorate
-
-
-def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False,
- env=None):
- """Call the given command(s)."""
- assert isinstance(commands, list)
- p = None
- for c in commands:
- try:
- dispcmd = str([c] + args)
- # remember shell=False, so use git.cmd on windows, not just git
- p = subprocess.Popen([c] + args, cwd=cwd, env=env,
- stdout=subprocess.PIPE,
- stderr=(subprocess.PIPE if hide_stderr
- else None))
- break
- except EnvironmentError:
- e = sys.exc_info()[1]
- if e.errno == errno.ENOENT:
- continue
- if verbose:
- print("unable to run %%s" %% dispcmd)
- print(e)
- return None, None
- else:
- if verbose:
- print("unable to find command, tried %%s" %% (commands,))
- return None, None
- stdout = p.communicate()[0].strip()
- if sys.version_info[0] >= 3:
- stdout = stdout.decode()
- if p.returncode != 0:
- if verbose:
- print("unable to run %%s (error)" %% dispcmd)
- print("stdout was %%s" %% stdout)
- return None, p.returncode
- return stdout, p.returncode
-
-
-def versions_from_parentdir(parentdir_prefix, root, verbose):
- """Try to determine the version from the parent directory name.
-
- Source tarballs conventionally unpack into a directory that includes both
- the project name and a version string. We will also support searching up
- two directory levels for an appropriately named parent directory
- """
- rootdirs = []
-
- for i in range(3):
- dirname = os.path.basename(root)
- if dirname.startswith(parentdir_prefix):
- return {"version": dirname[len(parentdir_prefix):],
- "full-revisionid": None,
- "dirty": False, "error": None, "date": None}
- else:
- rootdirs.append(root)
- root = os.path.dirname(root) # up a level
-
- if verbose:
- print("Tried directories %%s but none started with prefix %%s" %%
- (str(rootdirs), parentdir_prefix))
- raise NotThisMethod("rootdir doesn't start with parentdir_prefix")
-
-
-@register_vcs_handler("git", "get_keywords")
-def git_get_keywords(versionfile_abs):
- """Extract version information from the given file."""
- # the code embedded in _version.py can just fetch the value of these
- # keywords. When used from setup.py, we don't want to import _version.py,
- # so we do it with a regexp instead. This function is not used from
- # _version.py.
- keywords = {}
- try:
- f = open(versionfile_abs, "r")
- for line in f.readlines():
- if line.strip().startswith("git_refnames ="):
- mo = re.search(r'=\s*"(.*)"', line)
- if mo:
- keywords["refnames"] = mo.group(1)
- if line.strip().startswith("git_full ="):
- mo = re.search(r'=\s*"(.*)"', line)
- if mo:
- keywords["full"] = mo.group(1)
- if line.strip().startswith("git_date ="):
- mo = re.search(r'=\s*"(.*)"', line)
- if mo:
- keywords["date"] = mo.group(1)
- f.close()
- except EnvironmentError:
- pass
- return keywords
-
-
-@register_vcs_handler("git", "keywords")
-def git_versions_from_keywords(keywords, tag_prefix, verbose):
- """Get version information from git keywords."""
- if not keywords:
- raise NotThisMethod("no keywords at all, weird")
- date = keywords.get("date")
- if date is not None:
- # git-2.2.0 added "%%cI", which expands to an ISO-8601 -compliant
- # datestamp. However we prefer "%%ci" (which expands to an "ISO-8601
- # -like" string, which we must then edit to make compliant), because
- # it's been around since git-1.5.3, and it's too difficult to
- # discover which version we're using, or to work around using an
- # older one.
- date = date.strip().replace(" ", "T", 1).replace(" ", "", 1)
- refnames = keywords["refnames"].strip()
- if refnames.startswith("$Format"):
- if verbose:
- print("keywords are unexpanded, not using")
- raise NotThisMethod("unexpanded keywords, not a git-archive tarball")
- refs = set([r.strip() for r in refnames.strip("()").split(",")])
- # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of
- # just "foo-1.0". If we see a "tag: " prefix, prefer those.
- TAG = "tag: "
- tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)])
- if not tags:
- # Either we're using git < 1.8.3, or there really are no tags. We use
- # a heuristic: assume all version tags have a digit. The old git %%d
- # expansion behaves like git log --decorate=short and strips out the
- # refs/heads/ and refs/tags/ prefixes that would let us distinguish
- # between branches and tags. By ignoring refnames without digits, we
- # filter out many common branch names like "release" and
- # "stabilization", as well as "HEAD" and "master".
- tags = set([r for r in refs if re.search(r'\d', r)])
- if verbose:
- print("discarding '%%s', no digits" %% ",".join(refs - tags))
- if verbose:
- print("likely tags: %%s" %% ",".join(sorted(tags)))
- for ref in sorted(tags):
- # sorting will prefer e.g. "2.0" over "2.0rc1"
- if ref.startswith(tag_prefix):
- r = ref[len(tag_prefix):]
- if verbose:
- print("picking %%s" %% r)
- return {"version": r,
- "full-revisionid": keywords["full"].strip(),
- "dirty": False, "error": None,
- "date": date}
- # no suitable tags, so version is "0+unknown", but full hex is still there
- if verbose:
- print("no suitable tags, using unknown + full revision id")
- return {"version": "0+unknown",
- "full-revisionid": keywords["full"].strip(),
- "dirty": False, "error": "no suitable tags", "date": None}
-
-
-@register_vcs_handler("git", "pieces_from_vcs")
-def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
- """Get version from 'git describe' in the root of the source tree.
-
- This only gets called if the git-archive 'subst' keywords were *not*
- expanded, and _version.py hasn't already been rewritten with a short
- version string, meaning we're inside a checked out source tree.
- """
- GITS = ["git"]
- if sys.platform == "win32":
- GITS = ["git.cmd", "git.exe"]
-
- out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root,
- hide_stderr=True)
- if rc != 0:
- if verbose:
- print("Directory %%s not under git control" %% root)
- raise NotThisMethod("'git rev-parse --git-dir' returned error")
-
- # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty]
- # if there isn't one, this yields HEX[-dirty] (no NUM)
- describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty",
- "--always", "--long",
- "--match", "%%s*" %% tag_prefix],
- cwd=root)
- # --long was added in git-1.5.5
- if describe_out is None:
- raise NotThisMethod("'git describe' failed")
- describe_out = describe_out.strip()
- full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root)
- if full_out is None:
- raise NotThisMethod("'git rev-parse' failed")
- full_out = full_out.strip()
-
- pieces = {}
- pieces["long"] = full_out
- pieces["short"] = full_out[:7] # maybe improved later
- pieces["error"] = None
-
- # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty]
- # TAG might have hyphens.
- git_describe = describe_out
-
- # look for -dirty suffix
- dirty = git_describe.endswith("-dirty")
- pieces["dirty"] = dirty
- if dirty:
- git_describe = git_describe[:git_describe.rindex("-dirty")]
-
- # now we have TAG-NUM-gHEX or HEX
-
- if "-" in git_describe:
- # TAG-NUM-gHEX
- mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe)
- if not mo:
- # unparseable. Maybe git-describe is misbehaving?
- pieces["error"] = ("unable to parse git-describe output: '%%s'"
- %% describe_out)
- return pieces
-
- # tag
- full_tag = mo.group(1)
- if not full_tag.startswith(tag_prefix):
- if verbose:
- fmt = "tag '%%s' doesn't start with prefix '%%s'"
- print(fmt %% (full_tag, tag_prefix))
- pieces["error"] = ("tag '%%s' doesn't start with prefix '%%s'"
- %% (full_tag, tag_prefix))
- return pieces
- pieces["closest-tag"] = full_tag[len(tag_prefix):]
-
- # distance: number of commits since tag
- pieces["distance"] = int(mo.group(2))
-
- # commit: short hex revision ID
- pieces["short"] = mo.group(3)
-
- else:
- # HEX: no tags
- pieces["closest-tag"] = None
- count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"],
- cwd=root)
- pieces["distance"] = int(count_out) # total number of commits
-
- # commit date: see ISO-8601 comment in git_versions_from_keywords()
- date = run_command(GITS, ["show", "-s", "--format=%%ci", "HEAD"],
- cwd=root)[0].strip()
- pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1)
-
- return pieces
-
-
-def plus_or_dot(pieces):
- """Return a + if we don't already have one, else return a ."""
- if "+" in pieces.get("closest-tag", ""):
- return "."
- return "+"
-
-
-def render_pep440(pieces):
- """Build up version string, with post-release "local version identifier".
-
- Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you
- get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty
-
- Exceptions:
- 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty]
- """
- if pieces["closest-tag"]:
- rendered = pieces["closest-tag"]
- if pieces["distance"] or pieces["dirty"]:
- rendered += plus_or_dot(pieces)
- rendered += "%%d.g%%s" %% (pieces["distance"], pieces["short"])
- if pieces["dirty"]:
- rendered += ".dirty"
- else:
- # exception #1
- rendered = "0+untagged.%%d.g%%s" %% (pieces["distance"],
- pieces["short"])
- if pieces["dirty"]:
- rendered += ".dirty"
- return rendered
-
-
-def render_pep440_pre(pieces):
- """TAG[.post.devDISTANCE] -- No -dirty.
-
- Exceptions:
- 1: no tags. 0.post.devDISTANCE
- """
- if pieces["closest-tag"]:
- rendered = pieces["closest-tag"]
- if pieces["distance"]:
- rendered += ".post.dev%%d" %% pieces["distance"]
- else:
- # exception #1
- rendered = "0.post.dev%%d" %% pieces["distance"]
- return rendered
-
-
-def render_pep440_post(pieces):
- """TAG[.postDISTANCE[.dev0]+gHEX] .
-
- The ".dev0" means dirty. Note that .dev0 sorts backwards
- (a dirty tree will appear "older" than the corresponding clean one),
- but you shouldn't be releasing software with -dirty anyways.
-
- Exceptions:
- 1: no tags. 0.postDISTANCE[.dev0]
- """
- if pieces["closest-tag"]:
- rendered = pieces["closest-tag"]
- if pieces["distance"] or pieces["dirty"]:
- rendered += ".post%%d" %% pieces["distance"]
- if pieces["dirty"]:
- rendered += ".dev0"
- rendered += plus_or_dot(pieces)
- rendered += "g%%s" %% pieces["short"]
- else:
- # exception #1
- rendered = "0.post%%d" %% pieces["distance"]
- if pieces["dirty"]:
- rendered += ".dev0"
- rendered += "+g%%s" %% pieces["short"]
- return rendered
-
-
-def render_pep440_old(pieces):
- """TAG[.postDISTANCE[.dev0]] .
-
- The ".dev0" means dirty.
-
- Eexceptions:
- 1: no tags. 0.postDISTANCE[.dev0]
- """
- if pieces["closest-tag"]:
- rendered = pieces["closest-tag"]
- if pieces["distance"] or pieces["dirty"]:
- rendered += ".post%%d" %% pieces["distance"]
- if pieces["dirty"]:
- rendered += ".dev0"
- else:
- # exception #1
- rendered = "0.post%%d" %% pieces["distance"]
- if pieces["dirty"]:
- rendered += ".dev0"
- return rendered
-
-
-def render_git_describe(pieces):
- """TAG[-DISTANCE-gHEX][-dirty].
-
- Like 'git describe --tags --dirty --always'.
-
- Exceptions:
- 1: no tags. HEX[-dirty] (note: no 'g' prefix)
- """
- if pieces["closest-tag"]:
- rendered = pieces["closest-tag"]
- if pieces["distance"]:
- rendered += "-%%d-g%%s" %% (pieces["distance"], pieces["short"])
- else:
- # exception #1
- rendered = pieces["short"]
- if pieces["dirty"]:
- rendered += "-dirty"
- return rendered
-
-
-def render_git_describe_long(pieces):
- """TAG-DISTANCE-gHEX[-dirty].
-
- Like 'git describe --tags --dirty --always -long'.
- The distance/hash is unconditional.
-
- Exceptions:
- 1: no tags. HEX[-dirty] (note: no 'g' prefix)
- """
- if pieces["closest-tag"]:
- rendered = pieces["closest-tag"]
- rendered += "-%%d-g%%s" %% (pieces["distance"], pieces["short"])
- else:
- # exception #1
- rendered = pieces["short"]
- if pieces["dirty"]:
- rendered += "-dirty"
- return rendered
-
-
-def render(pieces, style):
- """Render the given version pieces into the requested style."""
- if pieces["error"]:
- return {"version": "unknown",
- "full-revisionid": pieces.get("long"),
- "dirty": None,
- "error": pieces["error"],
- "date": None}
-
- if not style or style == "default":
- style = "pep440" # the default
-
- if style == "pep440":
- rendered = render_pep440(pieces)
- elif style == "pep440-pre":
- rendered = render_pep440_pre(pieces)
- elif style == "pep440-post":
- rendered = render_pep440_post(pieces)
- elif style == "pep440-old":
- rendered = render_pep440_old(pieces)
- elif style == "git-describe":
- rendered = render_git_describe(pieces)
- elif style == "git-describe-long":
- rendered = render_git_describe_long(pieces)
- else:
- raise ValueError("unknown style '%%s'" %% style)
-
- return {"version": rendered, "full-revisionid": pieces["long"],
- "dirty": pieces["dirty"], "error": None,
- "date": pieces.get("date")}
-
-
-def get_versions():
- """Get version information or return default if unable to do so."""
- # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have
- # __file__, we can work backwards from there to the root. Some
- # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which
- # case we can only use expanded keywords.
-
- cfg = get_config()
- verbose = cfg.verbose
-
- try:
- return git_versions_from_keywords(get_keywords(), cfg.tag_prefix,
- verbose)
- except NotThisMethod:
- pass
-
- try:
- root = os.path.realpath(__file__)
- # versionfile_source is the relative path from the top of the source
- # tree (where the .git directory might live) to this file. Invert
- # this to find the root from __file__.
- for i in cfg.versionfile_source.split('/'):
- root = os.path.dirname(root)
- except NameError:
- return {"version": "0+unknown", "full-revisionid": None,
- "dirty": None,
- "error": "unable to find root of source tree",
- "date": None}
-
- try:
- pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose)
- return render(pieces, cfg.style)
- except NotThisMethod:
- pass
-
- try:
- if cfg.parentdir_prefix:
- return versions_from_parentdir(cfg.parentdir_prefix, root, verbose)
- except NotThisMethod:
- pass
-
- return {"version": "0+unknown", "full-revisionid": None,
- "dirty": None,
- "error": "unable to compute version", "date": None}
-'''
-
-
-@register_vcs_handler("git", "get_keywords")
-def git_get_keywords(versionfile_abs):
- """Extract version information from the given file."""
- # the code embedded in _version.py can just fetch the value of these
- # keywords. When used from setup.py, we don't want to import _version.py,
- # so we do it with a regexp instead. This function is not used from
- # _version.py.
- keywords = {}
- try:
- f = open(versionfile_abs, "r")
- for line in f.readlines():
- if line.strip().startswith("git_refnames ="):
- mo = re.search(r'=\s*"(.*)"', line)
- if mo:
- keywords["refnames"] = mo.group(1)
- if line.strip().startswith("git_full ="):
- mo = re.search(r'=\s*"(.*)"', line)
- if mo:
- keywords["full"] = mo.group(1)
- if line.strip().startswith("git_date ="):
- mo = re.search(r'=\s*"(.*)"', line)
- if mo:
- keywords["date"] = mo.group(1)
- f.close()
- except EnvironmentError:
- pass
- return keywords
-
-
-@register_vcs_handler("git", "keywords")
-def git_versions_from_keywords(keywords, tag_prefix, verbose):
- """Get version information from git keywords."""
- if not keywords:
- raise NotThisMethod("no keywords at all, weird")
- date = keywords.get("date")
- if date is not None:
- # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant
- # datestamp. However we prefer "%ci" (which expands to an "ISO-8601
- # -like" string, which we must then edit to make compliant), because
- # it's been around since git-1.5.3, and it's too difficult to
- # discover which version we're using, or to work around using an
- # older one.
- date = date.strip().replace(" ", "T", 1).replace(" ", "", 1)
- refnames = keywords["refnames"].strip()
- if refnames.startswith("$Format"):
- if verbose:
- print("keywords are unexpanded, not using")
- raise NotThisMethod("unexpanded keywords, not a git-archive tarball")
- refs = set([r.strip() for r in refnames.strip("()").split(",")])
- # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of
- # just "foo-1.0". If we see a "tag: " prefix, prefer those.
- TAG = "tag: "
- tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)])
- if not tags:
- # Either we're using git < 1.8.3, or there really are no tags. We use
- # a heuristic: assume all version tags have a digit. The old git %d
- # expansion behaves like git log --decorate=short and strips out the
- # refs/heads/ and refs/tags/ prefixes that would let us distinguish
- # between branches and tags. By ignoring refnames without digits, we
- # filter out many common branch names like "release" and
- # "stabilization", as well as "HEAD" and "master".
- tags = set([r for r in refs if re.search(r'\d', r)])
- if verbose:
- print("discarding '%s', no digits" % ",".join(refs - tags))
- if verbose:
- print("likely tags: %s" % ",".join(sorted(tags)))
- for ref in sorted(tags):
- # sorting will prefer e.g. "2.0" over "2.0rc1"
- if ref.startswith(tag_prefix):
- r = ref[len(tag_prefix):]
- if verbose:
- print("picking %s" % r)
- return {"version": r,
- "full-revisionid": keywords["full"].strip(),
- "dirty": False, "error": None,
- "date": date}
- # no suitable tags, so version is "0+unknown", but full hex is still there
- if verbose:
- print("no suitable tags, using unknown + full revision id")
- return {"version": "0+unknown",
- "full-revisionid": keywords["full"].strip(),
- "dirty": False, "error": "no suitable tags", "date": None}
-
-
-@register_vcs_handler("git", "pieces_from_vcs")
-def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
- """Get version from 'git describe' in the root of the source tree.
-
- This only gets called if the git-archive 'subst' keywords were *not*
- expanded, and _version.py hasn't already been rewritten with a short
- version string, meaning we're inside a checked out source tree.
- """
- GITS = ["git"]
- if sys.platform == "win32":
- GITS = ["git.cmd", "git.exe"]
-
- out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root,
- hide_stderr=True)
- if rc != 0:
- if verbose:
- print("Directory %s not under git control" % root)
- raise NotThisMethod("'git rev-parse --git-dir' returned error")
-
- # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty]
- # if there isn't one, this yields HEX[-dirty] (no NUM)
- describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty",
- "--always", "--long",
- "--match", "%s*" % tag_prefix],
- cwd=root)
- # --long was added in git-1.5.5
- if describe_out is None:
- raise NotThisMethod("'git describe' failed")
- describe_out = describe_out.strip()
- full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root)
- if full_out is None:
- raise NotThisMethod("'git rev-parse' failed")
- full_out = full_out.strip()
-
- pieces = {}
- pieces["long"] = full_out
- pieces["short"] = full_out[:7] # maybe improved later
- pieces["error"] = None
-
- # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty]
- # TAG might have hyphens.
- git_describe = describe_out
-
- # look for -dirty suffix
- dirty = git_describe.endswith("-dirty")
- pieces["dirty"] = dirty
- if dirty:
- git_describe = git_describe[:git_describe.rindex("-dirty")]
-
- # now we have TAG-NUM-gHEX or HEX
-
- if "-" in git_describe:
- # TAG-NUM-gHEX
- mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe)
- if not mo:
- # unparseable. Maybe git-describe is misbehaving?
- pieces["error"] = ("unable to parse git-describe output: '%s'"
- % describe_out)
- return pieces
-
- # tag
- full_tag = mo.group(1)
- if not full_tag.startswith(tag_prefix):
- if verbose:
- fmt = "tag '%s' doesn't start with prefix '%s'"
- print(fmt % (full_tag, tag_prefix))
- pieces["error"] = ("tag '%s' doesn't start with prefix '%s'"
- % (full_tag, tag_prefix))
- return pieces
- pieces["closest-tag"] = full_tag[len(tag_prefix):]
-
- # distance: number of commits since tag
- pieces["distance"] = int(mo.group(2))
-
- # commit: short hex revision ID
- pieces["short"] = mo.group(3)
-
- else:
- # HEX: no tags
- pieces["closest-tag"] = None
- count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"],
- cwd=root)
- pieces["distance"] = int(count_out) # total number of commits
-
- # commit date: see ISO-8601 comment in git_versions_from_keywords()
- date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"],
- cwd=root)[0].strip()
- pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1)
-
- return pieces
-
-
-def do_vcs_install(manifest_in, versionfile_source, ipy):
- """Git-specific installation logic for Versioneer.
-
- For Git, this means creating/changing .gitattributes to mark _version.py
- for export-subst keyword substitution.
- """
- GITS = ["git"]
- if sys.platform == "win32":
- GITS = ["git.cmd", "git.exe"]
- files = [manifest_in, versionfile_source]
- if ipy:
- files.append(ipy)
- try:
- me = __file__
- if me.endswith(".pyc") or me.endswith(".pyo"):
- me = os.path.splitext(me)[0] + ".py"
- versioneer_file = os.path.relpath(me)
- except NameError:
- versioneer_file = "versioneer.py"
- files.append(versioneer_file)
- present = False
- try:
- f = open(".gitattributes", "r")
- for line in f.readlines():
- if line.strip().startswith(versionfile_source):
- if "export-subst" in line.strip().split()[1:]:
- present = True
- f.close()
- except EnvironmentError:
- pass
- if not present:
- f = open(".gitattributes", "a+")
- f.write("%s export-subst\n" % versionfile_source)
- f.close()
- files.append(".gitattributes")
- run_command(GITS, ["add", "--"] + files)
-
-
-def versions_from_parentdir(parentdir_prefix, root, verbose):
- """Try to determine the version from the parent directory name.
-
- Source tarballs conventionally unpack into a directory that includes both
- the project name and a version string. We will also support searching up
- two directory levels for an appropriately named parent directory
- """
- rootdirs = []
-
- for i in range(3):
- dirname = os.path.basename(root)
- if dirname.startswith(parentdir_prefix):
- return {"version": dirname[len(parentdir_prefix):],
- "full-revisionid": None,
- "dirty": False, "error": None, "date": None}
- else:
- rootdirs.append(root)
- root = os.path.dirname(root) # up a level
-
- if verbose:
- print("Tried directories %s but none started with prefix %s" %
- (str(rootdirs), parentdir_prefix))
- raise NotThisMethod("rootdir doesn't start with parentdir_prefix")
-
-
-SHORT_VERSION_PY = """
-# This file was generated by 'versioneer.py' (0.18) from
-# revision-control system data, or from the parent directory name of an
-# unpacked source archive. Distribution tarballs contain a pre-generated copy
-# of this file.
-
-import json
-
-version_json = '''
-%s
-''' # END VERSION_JSON
-
-
-def get_versions():
- return json.loads(version_json)
-"""
-
-
-def versions_from_file(filename):
- """Try to determine the version from _version.py if present."""
- try:
- with open(filename) as f:
- contents = f.read()
- except EnvironmentError:
- raise NotThisMethod("unable to read _version.py")
- mo = re.search(r"version_json = '''\n(.*)''' # END VERSION_JSON",
- contents, re.M | re.S)
- if not mo:
- mo = re.search(r"version_json = '''\r\n(.*)''' # END VERSION_JSON",
- contents, re.M | re.S)
- if not mo:
- raise NotThisMethod("no version_json in _version.py")
- return json.loads(mo.group(1))
-
-
-def write_to_version_file(filename, versions):
- """Write the given version number to the given _version.py file."""
- os.unlink(filename)
- contents = json.dumps(versions, sort_keys=True,
- indent=1, separators=(",", ": "))
- with open(filename, "w") as f:
- f.write(SHORT_VERSION_PY % contents)
-
- print("set %s to '%s'" % (filename, versions["version"]))
-
-
-def plus_or_dot(pieces):
- """Return a + if we don't already have one, else return a ."""
- if "+" in pieces.get("closest-tag", ""):
- return "."
- return "+"
-
-
-def render_pep440(pieces):
- """Build up version string, with post-release "local version identifier".
-
- Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you
- get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty
-
- Exceptions:
- 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty]
- """
- if pieces["closest-tag"]:
- rendered = pieces["closest-tag"]
- if pieces["distance"] or pieces["dirty"]:
- rendered += plus_or_dot(pieces)
- rendered += "%d.g%s" % (pieces["distance"], pieces["short"])
- if pieces["dirty"]:
- rendered += ".dirty"
- else:
- # exception #1
- rendered = "0+untagged.%d.g%s" % (pieces["distance"],
- pieces["short"])
- if pieces["dirty"]:
- rendered += ".dirty"
- return rendered
-
-
-def render_pep440_pre(pieces):
- """TAG[.post.devDISTANCE] -- No -dirty.
-
- Exceptions:
- 1: no tags. 0.post.devDISTANCE
- """
- if pieces["closest-tag"]:
- rendered = pieces["closest-tag"]
- if pieces["distance"]:
- rendered += ".post.dev%d" % pieces["distance"]
- else:
- # exception #1
- rendered = "0.post.dev%d" % pieces["distance"]
- return rendered
-
-
-def render_pep440_post(pieces):
- """TAG[.postDISTANCE[.dev0]+gHEX] .
-
- The ".dev0" means dirty. Note that .dev0 sorts backwards
- (a dirty tree will appear "older" than the corresponding clean one),
- but you shouldn't be releasing software with -dirty anyways.
-
- Exceptions:
- 1: no tags. 0.postDISTANCE[.dev0]
- """
- if pieces["closest-tag"]:
- rendered = pieces["closest-tag"]
- if pieces["distance"] or pieces["dirty"]:
- rendered += ".post%d" % pieces["distance"]
- if pieces["dirty"]:
- rendered += ".dev0"
- rendered += plus_or_dot(pieces)
- rendered += "g%s" % pieces["short"]
- else:
- # exception #1
- rendered = "0.post%d" % pieces["distance"]
- if pieces["dirty"]:
- rendered += ".dev0"
- rendered += "+g%s" % pieces["short"]
- return rendered
-
-
-def render_pep440_old(pieces):
- """TAG[.postDISTANCE[.dev0]] .
-
- The ".dev0" means dirty.
-
- Eexceptions:
- 1: no tags. 0.postDISTANCE[.dev0]
- """
- if pieces["closest-tag"]:
- rendered = pieces["closest-tag"]
- if pieces["distance"] or pieces["dirty"]:
- rendered += ".post%d" % pieces["distance"]
- if pieces["dirty"]:
- rendered += ".dev0"
- else:
- # exception #1
- rendered = "0.post%d" % pieces["distance"]
- if pieces["dirty"]:
- rendered += ".dev0"
- return rendered
-
-
-def render_git_describe(pieces):
- """TAG[-DISTANCE-gHEX][-dirty].
-
- Like 'git describe --tags --dirty --always'.
-
- Exceptions:
- 1: no tags. HEX[-dirty] (note: no 'g' prefix)
- """
- if pieces["closest-tag"]:
- rendered = pieces["closest-tag"]
- if pieces["distance"]:
- rendered += "-%d-g%s" % (pieces["distance"], pieces["short"])
- else:
- # exception #1
- rendered = pieces["short"]
- if pieces["dirty"]:
- rendered += "-dirty"
- return rendered
-
-
-def render_git_describe_long(pieces):
- """TAG-DISTANCE-gHEX[-dirty].
-
- Like 'git describe --tags --dirty --always -long'.
- The distance/hash is unconditional.
-
- Exceptions:
- 1: no tags. HEX[-dirty] (note: no 'g' prefix)
- """
- if pieces["closest-tag"]:
- rendered = pieces["closest-tag"]
- rendered += "-%d-g%s" % (pieces["distance"], pieces["short"])
- else:
- # exception #1
- rendered = pieces["short"]
- if pieces["dirty"]:
- rendered += "-dirty"
- return rendered
-
-
-def render(pieces, style):
- """Render the given version pieces into the requested style."""
- if pieces["error"]:
- return {"version": "unknown",
- "full-revisionid": pieces.get("long"),
- "dirty": None,
- "error": pieces["error"],
- "date": None}
-
- if not style or style == "default":
- style = "pep440" # the default
-
- if style == "pep440":
- rendered = render_pep440(pieces)
- elif style == "pep440-pre":
- rendered = render_pep440_pre(pieces)
- elif style == "pep440-post":
- rendered = render_pep440_post(pieces)
- elif style == "pep440-old":
- rendered = render_pep440_old(pieces)
- elif style == "git-describe":
- rendered = render_git_describe(pieces)
- elif style == "git-describe-long":
- rendered = render_git_describe_long(pieces)
- else:
- raise ValueError("unknown style '%s'" % style)
-
- return {"version": rendered, "full-revisionid": pieces["long"],
- "dirty": pieces["dirty"], "error": None,
- "date": pieces.get("date")}
-
-
-class VersioneerBadRootError(Exception):
- """The project root directory is unknown or missing key files."""
-
-
-def get_versions(verbose=False):
- """Get the project version from whatever source is available.
-
- Returns dict with two keys: 'version' and 'full'.
- """
- if "versioneer" in sys.modules:
- # see the discussion in cmdclass.py:get_cmdclass()
- del sys.modules["versioneer"]
-
- root = get_root()
- cfg = get_config_from_root(root)
-
- assert cfg.VCS is not None, "please set [versioneer]VCS= in setup.cfg"
- handlers = HANDLERS.get(cfg.VCS)
- assert handlers, "unrecognized VCS '%s'" % cfg.VCS
- verbose = verbose or cfg.verbose
- assert cfg.versionfile_source is not None, \
- "please set versioneer.versionfile_source"
- assert cfg.tag_prefix is not None, "please set versioneer.tag_prefix"
-
- versionfile_abs = os.path.join(root, cfg.versionfile_source)
-
- # extract version from first of: _version.py, VCS command (e.g. 'git
- # describe'), parentdir. This is meant to work for developers using a
- # source checkout, for users of a tarball created by 'setup.py sdist',
- # and for users of a tarball/zipball created by 'git archive' or github's
- # download-from-tag feature or the equivalent in other VCSes.
-
- get_keywords_f = handlers.get("get_keywords")
- from_keywords_f = handlers.get("keywords")
- if get_keywords_f and from_keywords_f:
- try:
- keywords = get_keywords_f(versionfile_abs)
- ver = from_keywords_f(keywords, cfg.tag_prefix, verbose)
- if verbose:
- print("got version from expanded keyword %s" % ver)
- return ver
- except NotThisMethod:
- pass
-
- try:
- ver = versions_from_file(versionfile_abs)
- if verbose:
- print("got version from file %s %s" % (versionfile_abs, ver))
- return ver
- except NotThisMethod:
- pass
-
- from_vcs_f = handlers.get("pieces_from_vcs")
- if from_vcs_f:
- try:
- pieces = from_vcs_f(cfg.tag_prefix, root, verbose)
- ver = render(pieces, cfg.style)
- if verbose:
- print("got version from VCS %s" % ver)
- return ver
- except NotThisMethod:
- pass
-
- try:
- if cfg.parentdir_prefix:
- ver = versions_from_parentdir(cfg.parentdir_prefix, root, verbose)
- if verbose:
- print("got version from parentdir %s" % ver)
- return ver
- except NotThisMethod:
- pass
-
- if verbose:
- print("unable to compute version")
-
- return {"version": "0+unknown", "full-revisionid": None,
- "dirty": None, "error": "unable to compute version",
- "date": None}
-
-
-def get_version():
- """Get the short version string for this project."""
- return get_versions()["version"]
-
-
-def get_cmdclass():
- """Get the custom setuptools/distutils subclasses used by Versioneer."""
- if "versioneer" in sys.modules:
- del sys.modules["versioneer"]
- # this fixes the "python setup.py develop" case (also 'install' and
- # 'easy_install .'), in which subdependencies of the main project are
- # built (using setup.py bdist_egg) in the same python process. Assume
- # a main project A and a dependency B, which use different versions
- # of Versioneer. A's setup.py imports A's Versioneer, leaving it in
- # sys.modules by the time B's setup.py is executed, causing B to run
- # with the wrong versioneer. Setuptools wraps the sub-dep builds in a
- # sandbox that restores sys.modules to it's pre-build state, so the
- # parent is protected against the child's "import versioneer". By
- # removing ourselves from sys.modules here, before the child build
- # happens, we protect the child from the parent's versioneer too.
- # Also see https://github.com/warner/python-versioneer/issues/52
-
- cmds = {}
-
- # we add "version" to both distutils and setuptools
- from distutils.core import Command
-
- class cmd_version(Command):
- description = "report generated version string"
- user_options = []
- boolean_options = []
-
- def initialize_options(self):
- pass
-
- def finalize_options(self):
- pass
-
- def run(self):
- vers = get_versions(verbose=True)
- print("Version: %s" % vers["version"])
- print(" full-revisionid: %s" % vers.get("full-revisionid"))
- print(" dirty: %s" % vers.get("dirty"))
- print(" date: %s" % vers.get("date"))
- if vers["error"]:
- print(" error: %s" % vers["error"])
- cmds["version"] = cmd_version
-
- # we override "build_py" in both distutils and setuptools
- #
- # most invocation pathways end up running build_py:
- # distutils/build -> build_py
- # distutils/install -> distutils/build ->..
- # setuptools/bdist_wheel -> distutils/install ->..
- # setuptools/bdist_egg -> distutils/install_lib -> build_py
- # setuptools/install -> bdist_egg ->..
- # setuptools/develop -> ?
- # pip install:
- # copies source tree to a tempdir before running egg_info/etc
- # if .git isn't copied too, 'git describe' will fail
- # then does setup.py bdist_wheel, or sometimes setup.py install
- # setup.py egg_info -> ?
-
- # we override different "build_py" commands for both environments
- if "setuptools" in sys.modules:
- from setuptools.command.build_py import build_py as _build_py
- else:
- from distutils.command.build_py import build_py as _build_py
-
- class cmd_build_py(_build_py):
- def run(self):
- root = get_root()
- cfg = get_config_from_root(root)
- versions = get_versions()
- _build_py.run(self)
- # now locate _version.py in the new build/ directory and replace
- # it with an updated value
- if cfg.versionfile_build:
- target_versionfile = os.path.join(self.build_lib,
- cfg.versionfile_build)
- print("UPDATING %s" % target_versionfile)
- write_to_version_file(target_versionfile, versions)
- cmds["build_py"] = cmd_build_py
-
- if "cx_Freeze" in sys.modules: # cx_freeze enabled?
- from cx_Freeze.dist import build_exe as _build_exe
- # nczeczulin reports that py2exe won't like the pep440-style string
- # as FILEVERSION, but it can be used for PRODUCTVERSION, e.g.
- # setup(console=[{
- # "version": versioneer.get_version().split("+", 1)[0], # FILEVERSION
- # "product_version": versioneer.get_version(),
- # ...
-
- class cmd_build_exe(_build_exe):
- def run(self):
- root = get_root()
- cfg = get_config_from_root(root)
- versions = get_versions()
- target_versionfile = cfg.versionfile_source
- print("UPDATING %s" % target_versionfile)
- write_to_version_file(target_versionfile, versions)
-
- _build_exe.run(self)
- os.unlink(target_versionfile)
- with open(cfg.versionfile_source, "w") as f:
- LONG = LONG_VERSION_PY[cfg.VCS]
- f.write(LONG %
- {"DOLLAR": "$",
- "STYLE": cfg.style,
- "TAG_PREFIX": cfg.tag_prefix,
- "PARENTDIR_PREFIX": cfg.parentdir_prefix,
- "VERSIONFILE_SOURCE": cfg.versionfile_source,
- })
- cmds["build_exe"] = cmd_build_exe
- del cmds["build_py"]
-
- if 'py2exe' in sys.modules: # py2exe enabled?
- try:
- from py2exe.distutils_buildexe import py2exe as _py2exe # py3
- except ImportError:
- from py2exe.build_exe import py2exe as _py2exe # py2
-
- class cmd_py2exe(_py2exe):
- def run(self):
- root = get_root()
- cfg = get_config_from_root(root)
- versions = get_versions()
- target_versionfile = cfg.versionfile_source
- print("UPDATING %s" % target_versionfile)
- write_to_version_file(target_versionfile, versions)
-
- _py2exe.run(self)
- os.unlink(target_versionfile)
- with open(cfg.versionfile_source, "w") as f:
- LONG = LONG_VERSION_PY[cfg.VCS]
- f.write(LONG %
- {"DOLLAR": "$",
- "STYLE": cfg.style,
- "TAG_PREFIX": cfg.tag_prefix,
- "PARENTDIR_PREFIX": cfg.parentdir_prefix,
- "VERSIONFILE_SOURCE": cfg.versionfile_source,
- })
- cmds["py2exe"] = cmd_py2exe
-
- # we override different "sdist" commands for both environments
- if "setuptools" in sys.modules:
- from setuptools.command.sdist import sdist as _sdist
- else:
- from distutils.command.sdist import sdist as _sdist
-
- class cmd_sdist(_sdist):
- def run(self):
- versions = get_versions()
- self._versioneer_generated_versions = versions
- # unless we update this, the command will keep using the old
- # version
- self.distribution.metadata.version = versions["version"]
- return _sdist.run(self)
-
- def make_release_tree(self, base_dir, files):
- root = get_root()
- cfg = get_config_from_root(root)
- _sdist.make_release_tree(self, base_dir, files)
- # now locate _version.py in the new base_dir directory
- # (remembering that it may be a hardlink) and replace it with an
- # updated value
- target_versionfile = os.path.join(base_dir, cfg.versionfile_source)
- print("UPDATING %s" % target_versionfile)
- write_to_version_file(target_versionfile,
- self._versioneer_generated_versions)
- cmds["sdist"] = cmd_sdist
-
- return cmds
-
-
-CONFIG_ERROR = """
-setup.cfg is missing the necessary Versioneer configuration. You need
-a section like:
-
- [versioneer]
- VCS = git
- style = pep440
- versionfile_source = src/myproject/_version.py
- versionfile_build = myproject/_version.py
- tag_prefix =
- parentdir_prefix = myproject-
-
-You will also need to edit your setup.py to use the results:
-
- import versioneer
- setup(version=versioneer.get_version(),
- cmdclass=versioneer.get_cmdclass(), ...)
-
-Please read the docstring in ./versioneer.py for configuration instructions,
-edit setup.cfg, and re-run the installer or 'python versioneer.py setup'.
-"""
-
-SAMPLE_CONFIG = """
-# See the docstring in versioneer.py for instructions. Note that you must
-# re-run 'versioneer.py setup' after changing this section, and commit the
-# resulting files.
-
-[versioneer]
-#VCS = git
-#style = pep440
-#versionfile_source =
-#versionfile_build =
-#tag_prefix =
-#parentdir_prefix =
-
-"""
-
-INIT_PY_SNIPPET = """
-from ._version import get_versions
-__version__ = get_versions()['version']
-del get_versions
-"""
-
-
-def do_setup():
- """Main VCS-independent setup function for installing Versioneer."""
- root = get_root()
- try:
- cfg = get_config_from_root(root)
- except (EnvironmentError, configparser.NoSectionError,
- configparser.NoOptionError) as e:
- if isinstance(e, (EnvironmentError, configparser.NoSectionError)):
- print("Adding sample versioneer config to setup.cfg",
- file=sys.stderr)
- with open(os.path.join(root, "setup.cfg"), "a") as f:
- f.write(SAMPLE_CONFIG)
- print(CONFIG_ERROR, file=sys.stderr)
- return 1
-
- print(" creating %s" % cfg.versionfile_source)
- with open(cfg.versionfile_source, "w") as f:
- LONG = LONG_VERSION_PY[cfg.VCS]
- f.write(LONG % {"DOLLAR": "$",
- "STYLE": cfg.style,
- "TAG_PREFIX": cfg.tag_prefix,
- "PARENTDIR_PREFIX": cfg.parentdir_prefix,
- "VERSIONFILE_SOURCE": cfg.versionfile_source,
- })
-
- ipy = os.path.join(os.path.dirname(cfg.versionfile_source),
- "__init__.py")
- if os.path.exists(ipy):
- try:
- with open(ipy, "r") as f:
- old = f.read()
- except EnvironmentError:
- old = ""
- if INIT_PY_SNIPPET not in old:
- print(" appending to %s" % ipy)
- with open(ipy, "a") as f:
- f.write(INIT_PY_SNIPPET)
- else:
- print(" %s unmodified" % ipy)
- else:
- print(" %s doesn't exist, ok" % ipy)
- ipy = None
-
- # Make sure both the top-level "versioneer.py" and versionfile_source
- # (PKG/_version.py, used by runtime code) are in MANIFEST.in, so
- # they'll be copied into source distributions. Pip won't be able to
- # install the package without this.
- manifest_in = os.path.join(root, "MANIFEST.in")
- simple_includes = set()
- try:
- with open(manifest_in, "r") as f:
- for line in f:
- if line.startswith("include "):
- for include in line.split()[1:]:
- simple_includes.add(include)
- except EnvironmentError:
- pass
- # That doesn't cover everything MANIFEST.in can do
- # (http://docs.python.org/2/distutils/sourcedist.html#commands), so
- # it might give some false negatives. Appending redundant 'include'
- # lines is safe, though.
- if "versioneer.py" not in simple_includes:
- print(" appending 'versioneer.py' to MANIFEST.in")
- with open(manifest_in, "a") as f:
- f.write("include versioneer.py\n")
- else:
- print(" 'versioneer.py' already in MANIFEST.in")
- if cfg.versionfile_source not in simple_includes:
- print(" appending versionfile_source ('%s') to MANIFEST.in" %
- cfg.versionfile_source)
- with open(manifest_in, "a") as f:
- f.write("include %s\n" % cfg.versionfile_source)
- else:
- print(" versionfile_source already in MANIFEST.in")
-
- # Make VCS-specific changes. For git, this means creating/changing
- # .gitattributes to mark _version.py for export-subst keyword
- # substitution.
- do_vcs_install(manifest_in, cfg.versionfile_source, ipy)
- return 0
-
-
-def scan_setup_py():
- """Validate the contents of setup.py against Versioneer's expectations."""
- found = set()
- setters = False
- errors = 0
- with open("setup.py", "r") as f:
- for line in f.readlines():
- if "import versioneer" in line:
- found.add("import")
- if "versioneer.get_cmdclass()" in line:
- found.add("cmdclass")
- if "versioneer.get_version()" in line:
- found.add("get_version")
- if "versioneer.VCS" in line:
- setters = True
- if "versioneer.versionfile_source" in line:
- setters = True
- if len(found) != 3:
- print("")
- print("Your setup.py appears to be missing some important items")
- print("(but I might be wrong). Please make sure it has something")
- print("roughly like the following:")
- print("")
- print(" import versioneer")
- print(" setup( version=versioneer.get_version(),")
- print(" cmdclass=versioneer.get_cmdclass(), ...)")
- print("")
- errors += 1
- if setters:
- print("You should remove lines like 'versioneer.VCS = ' and")
- print("'versioneer.versionfile_source = ' . This configuration")
- print("now lives in setup.cfg, and should be removed from setup.py")
- print("")
- errors += 1
- return errors
-
-
-if __name__ == "__main__":
- cmd = sys.argv[1]
- if cmd == "setup":
- errors = do_setup()
- errors += scan_setup_py()
- if errors:
- sys.exit(1)