From 114922ba8e8e834ee075aee111ac06e3ad148cb2 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Wed, 2 Nov 2016 16:06:55 -0500 Subject: [PATCH 01/37] Test request construction (#91) * GET and POST tests verify headers, body, and query strings coming from `Endpoint` --- test/test_requests.py | 47 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 test/test_requests.py diff --git a/test/test_requests.py b/test/test_requests.py new file mode 100644 index 000000000..3e8011a0a --- /dev/null +++ b/test/test_requests.py @@ -0,0 +1,47 @@ +import unittest + +import requests +import requests_mock + +import tableauserverclient as TSC + + +class RequestTests(unittest.TestCase): + def setUp(self): + self.server = TSC.Server('http://test') + + # 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_make_get_request(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) + resp = self.server.workbooks._make_request(requests.get, + url, + content=None, + request_object=opts, + auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', + content_type='text/xml') + + self.assertEquals(resp.request.query, 'pagenumber=13&pagesize=13') + self.assertEquals(resp.request.headers['x-tableau-auth'], 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM') + self.assertEquals(resp.request.headers['content-type'], 'text/xml') + + 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', + request_object=None, + auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', + content_type='multipart/mixed') + self.assertEquals(resp.request.headers['x-tableau-auth'], 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM') + self.assertEquals(resp.request.headers['content-type'], 'multipart/mixed') + self.assertEquals(resp.request.body, b'1337') From 818645bf1652fa1b8bf2626eceb87cb54f6eb24b Mon Sep 17 00:00:00 2001 From: Ben Lower Date: Wed, 2 Nov 2016 12:55:06 -0700 Subject: [PATCH 02/37] new sample & new feature instructions Added a new sample (Initialize Server) and added instructions to the readme for how to add new features to the project. --- README.md | 10 ++++ samples/initialize_server.py | 105 +++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 samples/initialize_server.py diff --git a/README.md b/README.md index 2197b2adc..c3b924700 100644 --- a/README.md +++ b/README.md @@ -57,3 +57,13 @@ Move Workbook | [move_workbook_projects.py](./samples/move_workbook_projects.py) Set HTTP Options | [set_http_options.py](./samples/set_http_options.py) | Sets HTTP options on server and downloads workbooks. Explore Datasource | [explore_datasource.py](./samples/explore_datasource.py) | Demonstrates working with Tableau Datasource. Queries all datasources, picks one and populates its connections, then updates the datasource. Has additional flags for publish and download. Explore Workbook | [explore_workbook.py](./samples/explore_workbook.py) | Demonstrates working with Tableau Workbook. Queries all workbooks, picks one and populates its connections/views, then updates the workbook. Has additional flags for publish, download, and getting the preview image. Note: if you don't have permissions on the workbook the script retrieves from the server, the script will result in a 403033 error. This is expected. +Initialize Server | [initialize_server.py](./samples/initialize_server.py) | Shows how to intialize a Tableau Server with datasources and workbooks from the local file system. + + + +###Adding New Features + +1. Create an endpoint class for the new feature, following the structure of the other endpoints. Each endpoint usually has get, post, update, and delete operations that require making the url, creating the xml request if necesssary, sending the request and creating the target item object based on the server response. +2. Create an item class for the new feature, following the structure of the other item classes. Each item has properties that correspond to what attributes are sent to/received from the server (refer to docs amd Postman for attributes). Some items also require constants for user input that are limited to specific strings. After making all the properties, make the parsing method that takes the server response and creates an instances of the target item. If the corresponding endpoint class has an update function, then parsing is broken into multiple parts (refer to another item like workbook or datasource for example). +3. Add testing by getting real xml responses from the server, and asserting that all properties are parsed and set correctly. +4. Add samples to show users how to use the new feature. \ No newline at end of file diff --git a/samples/initialize_server.py b/samples/initialize_server.py new file mode 100644 index 000000000..2136dc588 --- /dev/null +++ b/samples/initialize_server.py @@ -0,0 +1,105 @@ +#### +# This script sets up a server. It uploads datasources and workbooks from the local filesystem. +# +# By default, all content is published to the Default project on the Default site. +#### + +import tableauserverclient as TSC +import argparse +import getpass +import logging +import glob + +def main(): + parser = argparse.ArgumentParser(description='Initialize a server with content.') + parser.add_argument('--server', '-s', required=True, help='server address') + 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('--site', '-si', required=False, default='Default', help='site to use') + parser.add_argument('--project', '-p', required=False, default='Default', help='project to use') + parser.add_argument('--username', '-u', required=True, help='username to sign into 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() + + password = getpass.getpass("Password: ") + + # 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.TableauAuth(args.username, password) + server = TSC.Server(args.server) + + with server.auth.sign_in(tableau_auth): + + ################################################################################ + # Step 2: Create the site we need only if it doesn't exist + ################################################################################ + print("Checking to see if we need to create the site...") + + all_sites, _ = server.sites.get() + existing_site = next((s for s in all_sites if s.name == args.site), None) + + # Create the site if it doesn't exist + if existing_site is None: + print("Site not found: {0} Creating it...").format(args.site) + new_site = TSC.SiteItem(name=args.site, content_url=args.site.replace(" ", ""), admin_mode=TSC.SiteItem.AdminMode.ContentAndUsers) + server.sites.create(new_site) + else: + print("Site {0} exists. Moving on...").format(args.site) + + + ################################################################################ + # Step 3: Sign-in to our target site + ################################################################################ + print("Starting our content upload...") + server_upload = TSC.Server(args.server) + tableau_auth.site = args.site + + with server_upload.auth.sign_in(tableau_auth): + + ################################################################################ + # Step 4: Create the project we need only if it doesn't exist + ################################################################################ + all_projects, _ = server_upload.projects.get() + project = next((p for p in all_projects if p.name == args.project), None) + + # Create our project if it doesn't exist + if project is None: + print("Project not found: {0} Creating it...").format(args.project) + new_project = TSC.ProjectItem(name=args.project) + project = server_upload.projects.create(new_project) + + ################################################################################ + # Step 5: Set up our content + # Publish datasources to our site and project + # Publish workbooks to our site and project + ################################################################################ + publish_datasources_to_site(server_upload, project, args.datasources_folder) + publish_workbooks_to_site(server_upload, project, args.workbooks_folder) + +def publish_datasources_to_site(server_object, project, folder): + 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)) + + +def publish_workbooks_to_site(server_object, project, folder): + 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)) + + +if __name__ == "__main__": + main() From 78b9684a9a6a18db0ca8ea72d1d4b3da08604195 Mon Sep 17 00:00:00 2001 From: Jared Dominguez Date: Thu, 3 Nov 2016 13:37:51 -0700 Subject: [PATCH 03/37] Initial documentation for TSC (#98) * First cut at API docs * Update jquery version for bootstrap compatibility * Incorporate review feedback * Add pagination docs * Add dev guide * Add docs for populating views and connections * Continue adding to api ref * Edits for existing content * Update readme to point to docs * Incorporate edits from PR --- .gitignore | 6 +- README.md | 18 +- docs/Gemfile | 3 + docs/Gemfile.lock | 129 +++++ docs/_config.yml | 17 + docs/_includes/docs_menu.html | 31 ++ docs/_includes/footer.html | 8 + docs/_includes/head.html | 15 + docs/_includes/header.html | 29 + docs/_includes/icon-github.svg | 1 + docs/_layouts/default.html | 34 ++ docs/_layouts/docs.html | 31 ++ docs/_layouts/home.html | 19 + docs/assets/logo.png | Bin 0 -> 2800 bytes docs/css/api_ref.css | 709 ++++++++++++++++++++++++ docs/css/extra.css | 14 + docs/css/github-highlight.css | 224 ++++++++ docs/css/main.css | 276 +++++++++ docs/docs/api-ref.md | 261 +++++++++ docs/docs/dev-guide.md | 96 ++++ docs/docs/filter-sort.md | 90 +++ docs/docs/index.md | 76 +++ docs/docs/page-through-results.md | 69 +++ docs/docs/populate-connections-views.md | 45 ++ docs/docs/samples.md | 55 ++ docs/docs/sign-in-out.md | 39 ++ docs/docs/versions.md | 45 ++ docs/index.md | 12 + 28 files changed, 2341 insertions(+), 11 deletions(-) create mode 100644 docs/Gemfile create mode 100644 docs/Gemfile.lock create mode 100644 docs/_config.yml create mode 100644 docs/_includes/docs_menu.html create mode 100644 docs/_includes/footer.html create mode 100644 docs/_includes/head.html create mode 100644 docs/_includes/header.html create mode 100644 docs/_includes/icon-github.svg create mode 100644 docs/_layouts/default.html create mode 100644 docs/_layouts/docs.html create mode 100644 docs/_layouts/home.html create mode 100644 docs/assets/logo.png create mode 100644 docs/css/api_ref.css create mode 100644 docs/css/extra.css create mode 100644 docs/css/github-highlight.css create mode 100644 docs/css/main.css create mode 100644 docs/docs/api-ref.md create mode 100644 docs/docs/dev-guide.md create mode 100644 docs/docs/filter-sort.md create mode 100644 docs/docs/index.md create mode 100644 docs/docs/page-through-results.md create mode 100644 docs/docs/populate-connections-views.md create mode 100644 docs/docs/samples.md create mode 100644 docs/docs/sign-in-out.md create mode 100644 docs/docs/versions.md create mode 100644 docs/index.md diff --git a/.gitignore b/.gitignore index 6e1779e11..3b7a9ab5a 100644 --- a/.gitignore +++ b/.gitignore @@ -141,4 +141,8 @@ $RECYCLE.BIN/ *.msp # Windows shortcuts -*.lnk \ No newline at end of file +*.lnk + +# Documentation +docs/_site/ +docs/.jekyll-metadata \ No newline at end of file diff --git a/README.md b/README.md index c3b924700..fde8457a6 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,14 @@ -# server-client-python -Tableau Server Client is a client library for the Tableau REST API. The Server Client is delightful to use and easy to love because it requires writing much less code than working directly with the REST API. +# Tableau Server Client (Python) -This repository contains Python source and sample files. +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: -###Getting Started -You must have Python installed. You can use either 2.7.X or 3.3 and later. +* Publish workbooks and data sources. +* Create users and groups. +* Query projects, sites, and more. -#### Installing the latest stable version (preferred) +This repository contains Python source code and sample files. -```text -pip install tableauserverclient -``` +For more information on installing and using TSC, see the documentation: #### Installing From Source @@ -66,4 +64,4 @@ Initialize Server | [initialize_server.py](./samples/initialize_server.py) | Sho 1. Create an endpoint class for the new feature, following the structure of the other endpoints. Each endpoint usually has get, post, update, and delete operations that require making the url, creating the xml request if necesssary, sending the request and creating the target item object based on the server response. 2. Create an item class for the new feature, following the structure of the other item classes. Each item has properties that correspond to what attributes are sent to/received from the server (refer to docs amd Postman for attributes). Some items also require constants for user input that are limited to specific strings. After making all the properties, make the parsing method that takes the server response and creates an instances of the target item. If the corresponding endpoint class has an update function, then parsing is broken into multiple parts (refer to another item like workbook or datasource for example). 3. Add testing by getting real xml responses from the server, and asserting that all properties are parsed and set correctly. -4. Add samples to show users how to use the new feature. \ No newline at end of file +4. Add samples to show users how to use the new feature. diff --git a/docs/Gemfile b/docs/Gemfile new file mode 100644 index 000000000..775d954bf --- /dev/null +++ b/docs/Gemfile @@ -0,0 +1,3 @@ +source 'https://rubygems.org' +gem 'github-pages', group: :jekyll_plugins + diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock new file mode 100644 index 000000000..e555d12be --- /dev/null +++ b/docs/Gemfile.lock @@ -0,0 +1,129 @@ +GEM + remote: https://rubygems.org/ + specs: + activesupport (4.2.6) + i18n (~> 0.7) + json (~> 1.7, >= 1.7.7) + minitest (~> 5.1) + thread_safe (~> 0.3, >= 0.3.4) + tzinfo (~> 1.1) + addressable (2.4.0) + coffee-script (2.4.1) + coffee-script-source + execjs + coffee-script-source (1.10.0) + colorator (0.1) + ethon (0.9.0) + ffi (>= 1.3.0) + execjs (2.7.0) + faraday (0.9.2) + multipart-post (>= 1.2, < 3) + ffi (1.9.10) + ffi (1.9.10-x86-mingw32) + gemoji (2.1.0) + github-pages (80) + github-pages-health-check (= 1.1.0) + jekyll (= 3.1.6) + jekyll-coffeescript (= 1.0.1) + jekyll-feed (= 0.5.1) + jekyll-gist (= 1.4.0) + jekyll-github-metadata (= 1.11.1) + jekyll-mentions (= 1.1.2) + jekyll-paginate (= 1.1.0) + jekyll-redirect-from (= 0.10.0) + jekyll-sass-converter (= 1.3.0) + jekyll-seo-tag (= 1.4.0) + jekyll-sitemap (= 0.10.0) + jemoji (= 0.6.2) + kramdown (= 1.10.0) + liquid (= 3.0.6) + listen (= 3.0.6) + mercenary (~> 0.3) + rouge (= 1.10.1) + terminal-table (~> 1.4) + github-pages-health-check (1.1.0) + addressable (~> 2.3) + net-dns (~> 0.8) + octokit (~> 4.0) + public_suffix (~> 1.4) + typhoeus (~> 0.7) + html-pipeline (2.4.1) + activesupport (>= 2, < 5) + nokogiri (>= 1.4) + i18n (0.7.0) + jekyll (3.1.6) + colorator (~> 0.1) + jekyll-sass-converter (~> 1.0) + jekyll-watch (~> 1.1) + kramdown (~> 1.3) + liquid (~> 3.0) + mercenary (~> 0.3.3) + rouge (~> 1.7) + safe_yaml (~> 1.0) + jekyll-coffeescript (1.0.1) + coffee-script (~> 2.2) + jekyll-feed (0.5.1) + jekyll-gist (1.4.0) + octokit (~> 4.2) + jekyll-github-metadata (1.11.1) + octokit (~> 4.0) + jekyll-mentions (1.1.2) + html-pipeline (~> 2.3) + jekyll (~> 3.0) + jekyll-paginate (1.1.0) + jekyll-redirect-from (0.10.0) + jekyll (>= 2.0) + jekyll-sass-converter (1.3.0) + sass (~> 3.2) + jekyll-seo-tag (1.4.0) + jekyll (~> 3.0) + jekyll-sitemap (0.10.0) + jekyll-watch (1.4.0) + listen (~> 3.0, < 3.1) + jemoji (0.6.2) + gemoji (~> 2.0) + html-pipeline (~> 2.2) + jekyll (>= 3.0) + json (1.8.3) + kramdown (1.10.0) + liquid (3.0.6) + listen (3.0.6) + rb-fsevent (>= 0.9.3) + rb-inotify (>= 0.9.7) + mercenary (0.3.6) + mini_portile2 (2.0.0) + minitest (5.9.0) + multipart-post (2.0.0) + net-dns (0.8.0) + nokogiri (1.6.7.2) + mini_portile2 (~> 2.0.0.rc2) + nokogiri (1.6.7.2-x86-mingw32) + mini_portile2 (~> 2.0.0.rc2) + octokit (4.3.0) + sawyer (~> 0.7.0, >= 0.5.3) + public_suffix (1.5.3) + rb-fsevent (0.9.7) + rb-inotify (0.9.7) + ffi (>= 0.5.0) + rouge (1.10.1) + safe_yaml (1.0.4) + sass (3.4.22) + sawyer (0.7.0) + addressable (>= 2.3.5, < 2.5) + faraday (~> 0.8, < 0.10) + terminal-table (1.5.2) + thread_safe (0.3.5) + typhoeus (0.8.0) + ethon (>= 0.8.0) + tzinfo (1.2.2) + thread_safe (~> 0.1) + +PLATFORMS + ruby + x86-mingw32 + +DEPENDENCIES + github-pages + +BUNDLED WITH + 1.12.5 diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 000000000..5ea15f228 --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1,17 @@ +# Site settings +title: Tableau Server Client Library (Python) +email: github@tableau.com +description: Simplify interactions with the Tableau Server REST API. +baseurl: "/server-client-python" +permalinks: pretty +defaults: + - + scope: + path: "" # Apply to all files + values: + layout: "default" + +# Build settings +markdown: kramdown +highlighter: rouge + diff --git a/docs/_includes/docs_menu.html b/docs/_includes/docs_menu.html new file mode 100644 index 000000000..13679b6fe --- /dev/null +++ b/docs/_includes/docs_menu.html @@ -0,0 +1,31 @@ + diff --git a/docs/_includes/footer.html b/docs/_includes/footer.html new file mode 100644 index 000000000..486c81d22 --- /dev/null +++ b/docs/_includes/footer.html @@ -0,0 +1,8 @@ + +
+
+ +

This site is open source. Suggestions and pull requests are welcome on our GitHub page.

+

© 2016 Tableau.

+
+
diff --git a/docs/_includes/head.html b/docs/_includes/head.html new file mode 100644 index 000000000..80ae0323a --- /dev/null +++ b/docs/_includes/head.html @@ -0,0 +1,15 @@ + + + + + {% if page.title %}{{ page.title | escape }}{% else %}{{ site.title | escape }}{% endif %} + + + + + + + + + + diff --git a/docs/_includes/header.html b/docs/_includes/header.html new file mode 100644 index 000000000..106578dfc --- /dev/null +++ b/docs/_includes/header.html @@ -0,0 +1,29 @@ + diff --git a/docs/_includes/icon-github.svg b/docs/_includes/icon-github.svg new file mode 100644 index 000000000..4422c4f5d --- /dev/null +++ b/docs/_includes/icon-github.svg @@ -0,0 +1 @@ + diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html new file mode 100644 index 000000000..38ee020bb --- /dev/null +++ b/docs/_layouts/default.html @@ -0,0 +1,34 @@ + + + + + {% include head.html %} + + + +
+ {% include header.html %} +
    + {% for post in site.posts %} +
    +

    {{ post.title }}

    +
    +

    Posted on {{ post.date | date: "%-d %B %Y" }}

    +
    +

    + {{ post.abstract }} +

    + {% if post.photoname %} + {% endif %} +
    +
    + {{ post.content }} +
    +
    + {% endfor %} +
+ {% include footer.html %} +
+ + + diff --git a/docs/_layouts/docs.html b/docs/_layouts/docs.html new file mode 100644 index 000000000..5355f63df --- /dev/null +++ b/docs/_layouts/docs.html @@ -0,0 +1,31 @@ +--- +layout: docs +--- + + + + + + {% include head.html %} + + + +
+ {% include header.html %} + {% include docs_menu.html %} + +
+

{{ page.title }}

+ +
+ {{ content }} + {% include footer.html %} +
+
+ + + diff --git a/docs/_layouts/home.html b/docs/_layouts/home.html new file mode 100644 index 000000000..c2cf32fcb --- /dev/null +++ b/docs/_layouts/home.html @@ -0,0 +1,19 @@ +--- +layout: home +--- + + + + + {% include head.html %} + + + +
+ {% include header.html %} + {{ content }} + {% include footer.html %} +
+ + + diff --git a/docs/assets/logo.png b/docs/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..60761152152291896e7b27f94d981fc82e71a2dd GIT binary patch literal 2800 zcmb7GdpuNWA3rk7jj(zc zA_9#@6Cwf+j{N!tppenAD|!*Tt? zar#(2+n89aH%lSV-lRb3+Ff~Q<4W3MbT z@VqTFFgsTxdBECEUTw_=QFxAOl%7~&W%r{p6`lSZYEWfw_N(j+_voCtm@(sQg^ZaR zsIPw<-y$EgstQv?0+7GAo#~0Ybf<5&_#AxMFXwb?)9ujtx3<&f(hsV490=3nB^pAJ zMTU9sKs7)h8oMvf0Qh2Q4X6li#W2BNGXt$=HfdHSpi}0iurAQrwHK3}3Gfe>Z7BX< zq3$f|V$_05hx;%OITN-*535$zVg^snD5$=sNBEW(^)1Yyj%k;vy{wVRpW9#M8}ico z`xoM42SX)!goCAfAkAyd301SblpHR7C55iE*E9iL;}XNM-OoAN8fZ={l9N%*^3B|5 z+V!G$`?J+2lS2Zi$d%7H)4u(#VPVgB&d*}qPUgG#ThLM3Ie<@F!WLyAXzZ~#FYjD+ zIcvcy&*fuR^=SCM2%}Q-v zc6@V!Cjpy)p^eN{4k)7%h1+@LE#?q-XV*rWJ!rK@qaIQK(v4YOFAB7x9QfDe6NM9T z-$s!EQY1{y|L>u^zOJag>xEZBY%Z{hcR3Wh0HoqajIrx=-0Sw7LfNocy;ckD zlN=YnmJy+F`L(I;)F4-+U7OKAJsbV3r2%VZ}eDU0r44YGzvYI}6J8SJ|guC1AP%`f-qACE3fV`|%cn zrm1{UtI64mKbG$DOOHzKkquq?rE8foh%;9Zm5nB+dL)iK+JaZ!U~6g!!hIxBnI&b# z$l_tno&l91PI7OBh3n}-#xAoh>oCc6XWvxt`wJTuf^d(s<}9GdK6Q<@;aQV|ORNy{ zSCSp6KuQ-%D(pXbx-1S)|y2kxwzf%4+Yp1u28EW3orO>jQfZQH*0SGO#V6wj~LH z<)E0yt_=N*&R#lMQ)g8PJQE`8ISv+%*X0_3T9W3rIcWicZqKG%Yk(AR--d?gIh72) z2r+UimAg<>@mA|l^qZc=n4@o@>6*ej!eE54DU+`cUpewrq~Ax-pgA#OG0qwL6UKbl z=eL@fTZ+?e-#8TA!rJd7GLVpNwr!iz;6k09xMrmF4 zt`Smbk^5c}plr=9^O)~g_!J7(`n47)Ylp(22O5Cjh!n;$4dB}~!?dtq_0Gs8N3eQ1 zP17Ubq4G{@a|0!xqt}$?3}}UEp`@G7$sOPMYInvg^IFEIqhj{zM)b;clX8_^($FpV zt<%~0;HRkUMv~TXfd4S@#1#${xvfNjuoX5p@EfZjDDz}~u|v7Nrpl4=zes(cc3LCn9^5yHcG4y0kRne# ztY1!+o3y&qA zttx95Z>yADY4Q91Ulv7;c6mO(mRHs9)^Np*XLpaxze8FIHcYEz0#evG5M-$;1_?3( za|j;>1-mGX;#mPCHLIkrNa>*{o6z_CiqqjK0lkSx&FzkS7Yk+B-62H&(gnE395AbQp4>LZYT`AK6 z;mN3uerOU4Zk;)^D5Po7bTw{9&P-|lK+$M(zbm&!Xc)N6RXFL6Tkw02sw!d+QCar@ z5bLMn&_+=8JJX;%;!;wr(0~AiZc26oNF#=17!(BX|MH;|H80s{HcH;Y$85@-7/*! normalize.css v1.1.3 | MIT License | git.io/normalize */ +/* 2 */ +/* ========================================================================== Tables ========================================================================== */ +/** Remove most spacing between table cells. */ +table { border-collapse: collapse; border-spacing: 0; } + +/* Visual Studio-like style based on original C# coloring by Jason Diamond */ +.hljs { display: inline-block; padding: 0.5em; background: white; color: black; } + +.hljs-comment, .hljs-annotation, .hljs-template_comment, .diff .hljs-header, .hljs-chunk, .apache .hljs-cbracket { color: #008000; } + +.hljs-keyword, .hljs-id, .hljs-built_in, .css .smalltalk .hljs-class, .hljs-winutils, .bash .hljs-variable, .tex .hljs-command, .hljs-request, .hljs-status, .nginx .hljs-title { color: #00f; } + +.xml .hljs-tag { color: #00f; } +.xml .hljs-tag .hljs-value { color: #00f; } + +.hljs-string, .hljs-title, .hljs-parent, .hljs-tag .hljs-value, .hljs-rules .hljs-value { color: #a31515; } + +.ruby .hljs-symbol { color: #a31515; } +.ruby .hljs-symbol .hljs-string { color: #a31515; } + +.hljs-template_tag, .django .hljs-variable, .hljs-addition, .hljs-flow, .hljs-stream, .apache .hljs-tag, .hljs-date, .tex .hljs-formula, .coffeescript .hljs-attribute { color: #a31515; } + +.ruby .hljs-string, .hljs-decorator, .hljs-filter .hljs-argument, .hljs-localvars, .hljs-array, .hljs-attr_selector, .hljs-pseudo, .hljs-pi, .hljs-doctype, .hljs-deletion, .hljs-envvar, .hljs-shebang, .hljs-preprocessor, .hljs-pragma, .userType, .apache .hljs-sqbracket, .nginx .hljs-built_in, .tex .hljs-special, .hljs-prompt { color: #2b91af; } + +.hljs-phpdoc, .hljs-javadoc, .hljs-xmlDocTag { color: #808080; } + +.vhdl .hljs-typename { font-weight: bold; } +.vhdl .hljs-string { color: #666666; } +.vhdl .hljs-literal { color: #a31515; } +.vhdl .hljs-attribute { color: #00b0e8; } + +.xml .hljs-attribute { color: #f00; } + +.col > :first-child, .col-1 > :first-child, .col-2 > :first-child, .col-3 > :first-child, .col-4 > :first-child, .col-5 > :first-child, .col-6 > :first-child, .col-7 > :first-child, .col-8 > :first-child, .col-9 > :first-child, .col-10 > :first-child, .col-11 > :first-child, .tsd-panel > :first-child, ul.tsd-descriptions > li > :first-child, .col > :first-child > :first-child, .col-1 > :first-child > :first-child, .col-2 > :first-child > :first-child, .col-3 > :first-child > :first-child, .col-4 > :first-child > :first-child, .col-5 > :first-child > :first-child, .col-6 > :first-child > :first-child, .col-7 > :first-child > :first-child, .col-8 > :first-child > :first-child, .col-9 > :first-child > :first-child, .col-10 > :first-child > :first-child, .col-11 > :first-child > :first-child, .tsd-panel > :first-child > :first-child, ul.tsd-descriptions > li > :first-child > :first-child, .col > :first-child > :first-child > :first-child, .col-1 > :first-child > :first-child > :first-child, .col-2 > :first-child > :first-child > :first-child, .col-3 > :first-child > :first-child > :first-child, .col-4 > :first-child > :first-child > :first-child, .col-5 > :first-child > :first-child > :first-child, .col-6 > :first-child > :first-child > :first-child, .col-7 > :first-child > :first-child > :first-child, .col-8 > :first-child > :first-child > :first-child, .col-9 > :first-child > :first-child > :first-child, .col-10 > :first-child > :first-child > :first-child, .col-11 > :first-child > :first-child > :first-child, .tsd-panel > :first-child > :first-child > :first-child, ul.tsd-descriptions > li > :first-child > :first-child > :first-child { margin-top: 0; } +.col > :last-child, .col-1 > :last-child, .col-2 > :last-child, .col-3 > :last-child, .col-4 > :last-child, .col-5 > :last-child, .col-6 > :last-child, .col-7 > :last-child, .col-8 > :last-child, .col-9 > :last-child, .col-10 > :last-child, .col-11 > :last-child, .tsd-panel > :last-child, ul.tsd-descriptions > li > :last-child, .col > :last-child > :last-child, .col-1 > :last-child > :last-child, .col-2 > :last-child > :last-child, .col-3 > :last-child > :last-child, .col-4 > :last-child > :last-child, .col-5 > :last-child > :last-child, .col-6 > :last-child > :last-child, .col-7 > :last-child > :last-child, .col-8 > :last-child > :last-child, .col-9 > :last-child > :last-child, .col-10 > :last-child > :last-child, .col-11 > :last-child > :last-child, .tsd-panel > :last-child > :last-child, ul.tsd-descriptions > li > :last-child > :last-child, .col > :last-child > :last-child > :last-child, .col-1 > :last-child > :last-child > :last-child, .col-2 > :last-child > :last-child > :last-child, .col-3 > :last-child > :last-child > :last-child, .col-4 > :last-child > :last-child > :last-child, .col-5 > :last-child > :last-child > :last-child, .col-6 > :last-child > :last-child > :last-child, .col-7 > :last-child > :last-child > :last-child, .col-8 > :last-child > :last-child > :last-child, .col-9 > :last-child > :last-child > :last-child, .col-10 > :last-child > :last-child > :last-child, .col-11 > :last-child > :last-child > :last-child, .tsd-panel > :last-child > :last-child > :last-child, ul.tsd-descriptions > li > :last-child > :last-child > :last-child { margin-bottom: 0; } + +@media (max-width: 640px) { .container { padding: 0 20px; } } + +.container-main { padding-bottom: 200px; } + +.row { position: relative; margin: 0 -10px; } +.row:after { visibility: hidden; display: block; content: ""; clear: both; height: 0; } + +.col, .col-1, .col-2, .col-3, .col-4, .col-5, .col-6, .col-7, .col-8, .col-9, .col-10, .col-11 { box-sizing: border-box; float: left; padding: 0 10px; } + +.col-1 { width: 8.33333%; } + +.offset-1 { margin-left: 8.33333%; } + +.col-2 { width: 16.66667%; } + +.offset-2 { margin-left: 16.66667%; } + +.col-3 { width: 25%; } + +.offset-3 { margin-left: 25%; } + +.col-4 { width: 33.33333%; } + +.offset-4 { margin-left: 33.33333%; } + +.col-5 { width: 41.66667%; } + +.offset-5 { margin-left: 41.66667%; } + +.col-6 { width: 50%; } + +.offset-6 { margin-left: 50%; } + +.col-7 { width: 58.33333%; } + +.offset-7 { margin-left: 58.33333%; } + +.col-8 { width: 66.66667%; } + +.offset-8 { margin-left: 66.66667%; } + +.col-9 { width: 75%; } + +.offset-9 { margin-left: 75%; } + +.col-10 { width: 83.33333%; } + +.offset-10 { margin-left: 83.33333%; } + +.col-11 { width: 91.66667%; } + +.offset-11 { margin-left: 91.66667%; } + +.tsd-kind-icon { display: block; position: relative; padding-left: 20px; text-indent: -20px; } +.tsd-kind-icon:before { content: ''; display: inline-block; vertical-align: middle; width: 17px; height: 17px; margin: 0 3px 2px 0; background-image: url(data:image/png;base64,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); } +@media (-webkit-min-device-pixel-ratio: 1.5), (min-device-pixel-ratio: 1.5), (min-resolution: 144dpi) { .tsd-kind-icon:before { background-image: url(data:image/png;base64,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); background-size: 238px 204px; } } + +.tsd-signature.tsd-kind-icon:before { background-position: 0 -153px; } + +.tsd-kind-object-literal > .tsd-kind-icon:before { background-position: 0px -17px; } +.tsd-kind-object-literal.tsd-is-protected > .tsd-kind-icon:before { background-position: -17px -17px; } +.tsd-kind-object-literal.tsd-is-private > .tsd-kind-icon:before { background-position: -34px -17px; } + +.tsd-kind-class > .tsd-kind-icon:before { background-position: 0px -34px; } +.tsd-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -17px -34px; } +.tsd-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -34px -34px; } + +.tsd-kind-class.tsd-has-type-parameter > .tsd-kind-icon:before { background-position: 0px -51px; } +.tsd-kind-class.tsd-has-type-parameter.tsd-is-protected > .tsd-kind-icon:before { background-position: -17px -51px; } +.tsd-kind-class.tsd-has-type-parameter.tsd-is-private > .tsd-kind-icon:before { background-position: -34px -51px; } + +.tsd-kind-interface > .tsd-kind-icon:before { background-position: 0px -68px; } +.tsd-kind-interface.tsd-is-protected > .tsd-kind-icon:before { background-position: -17px -68px; } +.tsd-kind-interface.tsd-is-private > .tsd-kind-icon:before { background-position: -34px -68px; } + +.tsd-kind-interface.tsd-has-type-parameter > .tsd-kind-icon:before { background-position: 0px -85px; } +.tsd-kind-interface.tsd-has-type-parameter.tsd-is-protected > .tsd-kind-icon:before { background-position: -17px -85px; } +.tsd-kind-interface.tsd-has-type-parameter.tsd-is-private > .tsd-kind-icon:before { background-position: -34px -85px; } + +.tsd-kind-module > .tsd-kind-icon:before { background-position: 0px -102px; } +.tsd-kind-module.tsd-is-protected > .tsd-kind-icon:before { background-position: -17px -102px; } +.tsd-kind-module.tsd-is-private > .tsd-kind-icon:before { background-position: -34px -102px; } + +.tsd-kind-external-module > .tsd-kind-icon:before { background-position: 0px -102px; } +.tsd-kind-external-module.tsd-is-protected > .tsd-kind-icon:before { background-position: -17px -102px; } +.tsd-kind-external-module.tsd-is-private > .tsd-kind-icon:before { background-position: -34px -102px; } + +.tsd-kind-enum > .tsd-kind-icon:before { background-position: 0px -119px; } +.tsd-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -17px -119px; } +.tsd-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -34px -119px; } + +.tsd-kind-enum-member > .tsd-kind-icon:before { background-position: 0px -136px; } +.tsd-kind-enum-member.tsd-is-protected > .tsd-kind-icon:before { background-position: -17px -136px; } +.tsd-kind-enum-member.tsd-is-private > .tsd-kind-icon:before { background-position: -34px -136px; } + +.tsd-kind-signature > .tsd-kind-icon:before { background-position: 0px -153px; } +.tsd-kind-signature.tsd-is-protected > .tsd-kind-icon:before { background-position: -17px -153px; } +.tsd-kind-signature.tsd-is-private > .tsd-kind-icon:before { background-position: -34px -153px; } + +.tsd-kind-type-alias > .tsd-kind-icon:before { background-position: 0px -170px; } +.tsd-kind-type-alias.tsd-is-protected > .tsd-kind-icon:before { background-position: -17px -170px; } +.tsd-kind-type-alias.tsd-is-private > .tsd-kind-icon:before { background-position: -34px -170px; } + +.tsd-kind-variable > .tsd-kind-icon:before { background-position: -136px -0px; } +.tsd-kind-variable.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -0px; } +.tsd-kind-variable.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -0px; } +.tsd-kind-variable.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -0px; } +.tsd-kind-variable.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -0px; } +.tsd-kind-variable.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -0px; } +.tsd-kind-variable.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -0px; } +.tsd-kind-variable.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -0px; } +.tsd-kind-variable.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -0px; } +.tsd-kind-variable.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -0px; } +.tsd-kind-variable.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -0px; } +.tsd-kind-variable.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -0px; } +.tsd-kind-variable.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -0px; } + +.tsd-kind-property > .tsd-kind-icon:before { background-position: -136px -0px; } +.tsd-kind-property.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -0px; } +.tsd-kind-property.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -0px; } +.tsd-kind-property.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -0px; } +.tsd-kind-property.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -0px; } +.tsd-kind-property.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -0px; } +.tsd-kind-property.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -0px; } +.tsd-kind-property.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -0px; } +.tsd-kind-property.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -0px; } +.tsd-kind-property.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -0px; } +.tsd-kind-property.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -0px; } +.tsd-kind-property.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -0px; } +.tsd-kind-property.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -0px; } + +.tsd-kind-get-signature > .tsd-kind-icon:before { background-position: -136px -17px; } +.tsd-kind-get-signature.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -17px; } +.tsd-kind-get-signature.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -17px; } +.tsd-kind-get-signature.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -17px; } +.tsd-kind-get-signature.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -17px; } +.tsd-kind-get-signature.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -17px; } +.tsd-kind-get-signature.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -17px; } +.tsd-kind-get-signature.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -17px; } +.tsd-kind-get-signature.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -17px; } +.tsd-kind-get-signature.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -17px; } +.tsd-kind-get-signature.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -17px; } +.tsd-kind-get-signature.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -17px; } +.tsd-kind-get-signature.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -17px; } + +.tsd-kind-set-signature > .tsd-kind-icon:before { background-position: -136px -34px; } +.tsd-kind-set-signature.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -34px; } +.tsd-kind-set-signature.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -34px; } +.tsd-kind-set-signature.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -34px; } +.tsd-kind-set-signature.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -34px; } +.tsd-kind-set-signature.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -34px; } +.tsd-kind-set-signature.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -34px; } +.tsd-kind-set-signature.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -34px; } +.tsd-kind-set-signature.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -34px; } +.tsd-kind-set-signature.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -34px; } +.tsd-kind-set-signature.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -34px; } +.tsd-kind-set-signature.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -34px; } +.tsd-kind-set-signature.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -34px; } + +.tsd-kind-accessor > .tsd-kind-icon:before { background-position: -136px -51px; } +.tsd-kind-accessor.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -51px; } +.tsd-kind-accessor.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -51px; } +.tsd-kind-accessor.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -51px; } +.tsd-kind-accessor.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -51px; } +.tsd-kind-accessor.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -51px; } +.tsd-kind-accessor.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -51px; } +.tsd-kind-accessor.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -51px; } +.tsd-kind-accessor.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -51px; } +.tsd-kind-accessor.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -51px; } +.tsd-kind-accessor.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -51px; } +.tsd-kind-accessor.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -51px; } +.tsd-kind-accessor.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -51px; } + +.tsd-kind-function > .tsd-kind-icon:before { background-position: -136px -68px; } +.tsd-kind-function.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -68px; } +.tsd-kind-function.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -68px; } +.tsd-kind-function.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -68px; } +.tsd-kind-function.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -68px; } +.tsd-kind-function.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -68px; } +.tsd-kind-function.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -68px; } +.tsd-kind-function.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -68px; } +.tsd-kind-function.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -68px; } +.tsd-kind-function.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -68px; } +.tsd-kind-function.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -68px; } +.tsd-kind-function.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -68px; } +.tsd-kind-function.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -68px; } + +.tsd-kind-method > .tsd-kind-icon:before { background-position: -136px -68px; } +.tsd-kind-method.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -68px; } +.tsd-kind-method.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -68px; } +.tsd-kind-method.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -68px; } +.tsd-kind-method.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -68px; } +.tsd-kind-method.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -68px; } +.tsd-kind-method.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -68px; } +.tsd-kind-method.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -68px; } +.tsd-kind-method.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -68px; } +.tsd-kind-method.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -68px; } +.tsd-kind-method.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -68px; } +.tsd-kind-method.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -68px; } +.tsd-kind-method.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -68px; } + +.tsd-kind-call-signature > .tsd-kind-icon:before { background-position: -136px -68px; } +.tsd-kind-call-signature.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -68px; } +.tsd-kind-call-signature.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -68px; } +.tsd-kind-call-signature.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -68px; } +.tsd-kind-call-signature.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -68px; } +.tsd-kind-call-signature.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -68px; } +.tsd-kind-call-signature.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -68px; } +.tsd-kind-call-signature.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -68px; } +.tsd-kind-call-signature.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -68px; } +.tsd-kind-call-signature.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -68px; } +.tsd-kind-call-signature.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -68px; } +.tsd-kind-call-signature.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -68px; } +.tsd-kind-call-signature.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -68px; } + +.tsd-kind-function.tsd-has-type-parameter > .tsd-kind-icon:before { background-position: -136px -85px; } +.tsd-kind-function.tsd-has-type-parameter.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -85px; } +.tsd-kind-function.tsd-has-type-parameter.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -85px; } +.tsd-kind-function.tsd-has-type-parameter.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -85px; } +.tsd-kind-function.tsd-has-type-parameter.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -85px; } +.tsd-kind-function.tsd-has-type-parameter.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -85px; } +.tsd-kind-function.tsd-has-type-parameter.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -85px; } +.tsd-kind-function.tsd-has-type-parameter.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -85px; } +.tsd-kind-function.tsd-has-type-parameter.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -85px; } +.tsd-kind-function.tsd-has-type-parameter.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -85px; } +.tsd-kind-function.tsd-has-type-parameter.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -85px; } +.tsd-kind-function.tsd-has-type-parameter.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -85px; } +.tsd-kind-function.tsd-has-type-parameter.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -85px; } + +.tsd-kind-method.tsd-has-type-parameter > .tsd-kind-icon:before { background-position: -136px -85px; } +.tsd-kind-method.tsd-has-type-parameter.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -85px; } +.tsd-kind-method.tsd-has-type-parameter.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -85px; } +.tsd-kind-method.tsd-has-type-parameter.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -85px; } +.tsd-kind-method.tsd-has-type-parameter.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -85px; } +.tsd-kind-method.tsd-has-type-parameter.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -85px; } +.tsd-kind-method.tsd-has-type-parameter.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -85px; } +.tsd-kind-method.tsd-has-type-parameter.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -85px; } +.tsd-kind-method.tsd-has-type-parameter.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -85px; } +.tsd-kind-method.tsd-has-type-parameter.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -85px; } +.tsd-kind-method.tsd-has-type-parameter.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -85px; } +.tsd-kind-method.tsd-has-type-parameter.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -85px; } +.tsd-kind-method.tsd-has-type-parameter.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -85px; } + +.tsd-kind-constructor > .tsd-kind-icon:before { background-position: -136px -102px; } +.tsd-kind-constructor.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -102px; } +.tsd-kind-constructor.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -102px; } +.tsd-kind-constructor.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -102px; } +.tsd-kind-constructor.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -102px; } +.tsd-kind-constructor.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -102px; } +.tsd-kind-constructor.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -102px; } +.tsd-kind-constructor.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -102px; } +.tsd-kind-constructor.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -102px; } +.tsd-kind-constructor.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -102px; } +.tsd-kind-constructor.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -102px; } +.tsd-kind-constructor.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -102px; } +.tsd-kind-constructor.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -102px; } + +.tsd-kind-constructor-signature > .tsd-kind-icon:before { background-position: -136px -102px; } +.tsd-kind-constructor-signature.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -102px; } +.tsd-kind-constructor-signature.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -102px; } +.tsd-kind-constructor-signature.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -102px; } +.tsd-kind-constructor-signature.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -102px; } +.tsd-kind-constructor-signature.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -102px; } +.tsd-kind-constructor-signature.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -102px; } +.tsd-kind-constructor-signature.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -102px; } +.tsd-kind-constructor-signature.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -102px; } +.tsd-kind-constructor-signature.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -102px; } +.tsd-kind-constructor-signature.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -102px; } +.tsd-kind-constructor-signature.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -102px; } +.tsd-kind-constructor-signature.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -102px; } + +.tsd-kind-index-signature > .tsd-kind-icon:before { background-position: -136px -119px; } +.tsd-kind-index-signature.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -119px; } +.tsd-kind-index-signature.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -119px; } +.tsd-kind-index-signature.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -119px; } +.tsd-kind-index-signature.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -119px; } +.tsd-kind-index-signature.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -119px; } +.tsd-kind-index-signature.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -119px; } +.tsd-kind-index-signature.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -119px; } +.tsd-kind-index-signature.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -119px; } +.tsd-kind-index-signature.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -119px; } +.tsd-kind-index-signature.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -119px; } +.tsd-kind-index-signature.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -119px; } +.tsd-kind-index-signature.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -119px; } + +.tsd-kind-event > .tsd-kind-icon:before { background-position: -136px -136px; } +.tsd-kind-event.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -136px; } +.tsd-kind-event.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -136px; } +.tsd-kind-event.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -136px; } +.tsd-kind-event.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -136px; } +.tsd-kind-event.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -136px; } +.tsd-kind-event.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -136px; } +.tsd-kind-event.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -136px; } +.tsd-kind-event.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -136px; } +.tsd-kind-event.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -136px; } +.tsd-kind-event.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -136px; } +.tsd-kind-event.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -136px; } +.tsd-kind-event.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -136px; } + +.tsd-is-static > .tsd-kind-icon:before { background-position: -136px -153px; } +.tsd-is-static.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -153px; } +.tsd-is-static.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -153px; } +.tsd-is-static.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -153px; } +.tsd-is-static.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -153px; } +.tsd-is-static.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -153px; } +.tsd-is-static.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -153px; } +.tsd-is-static.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -153px; } +.tsd-is-static.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -153px; } +.tsd-is-static.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -153px; } +.tsd-is-static.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -153px; } +.tsd-is-static.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -153px; } +.tsd-is-static.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -153px; } + +.tsd-is-static.tsd-kind-function > .tsd-kind-icon:before { background-position: -136px -170px; } +.tsd-is-static.tsd-kind-function.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -170px; } +.tsd-is-static.tsd-kind-function.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -170px; } +.tsd-is-static.tsd-kind-function.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -170px; } +.tsd-is-static.tsd-kind-function.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -170px; } +.tsd-is-static.tsd-kind-function.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -170px; } +.tsd-is-static.tsd-kind-function.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -170px; } +.tsd-is-static.tsd-kind-function.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -170px; } +.tsd-is-static.tsd-kind-function.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -170px; } +.tsd-is-static.tsd-kind-function.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -170px; } +.tsd-is-static.tsd-kind-function.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -170px; } +.tsd-is-static.tsd-kind-function.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -170px; } +.tsd-is-static.tsd-kind-function.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -170px; } + +.tsd-is-static.tsd-kind-method > .tsd-kind-icon:before { background-position: -136px -170px; } +.tsd-is-static.tsd-kind-method.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -170px; } +.tsd-is-static.tsd-kind-method.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -170px; } +.tsd-is-static.tsd-kind-method.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -170px; } +.tsd-is-static.tsd-kind-method.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -170px; } +.tsd-is-static.tsd-kind-method.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -170px; } +.tsd-is-static.tsd-kind-method.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -170px; } +.tsd-is-static.tsd-kind-method.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -170px; } +.tsd-is-static.tsd-kind-method.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -170px; } +.tsd-is-static.tsd-kind-method.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -170px; } +.tsd-is-static.tsd-kind-method.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -170px; } +.tsd-is-static.tsd-kind-method.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -170px; } +.tsd-is-static.tsd-kind-method.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -170px; } + +.tsd-is-static.tsd-kind-call-signature > .tsd-kind-icon:before { background-position: -136px -170px; } +.tsd-is-static.tsd-kind-call-signature.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -170px; } +.tsd-is-static.tsd-kind-call-signature.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -170px; } +.tsd-is-static.tsd-kind-call-signature.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -170px; } +.tsd-is-static.tsd-kind-call-signature.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -170px; } +.tsd-is-static.tsd-kind-call-signature.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -170px; } +.tsd-is-static.tsd-kind-call-signature.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -170px; } +.tsd-is-static.tsd-kind-call-signature.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -170px; } +.tsd-is-static.tsd-kind-call-signature.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -170px; } +.tsd-is-static.tsd-kind-call-signature.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -170px; } +.tsd-is-static.tsd-kind-call-signature.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -170px; } +.tsd-is-static.tsd-kind-call-signature.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -170px; } +.tsd-is-static.tsd-kind-call-signature.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -170px; } + +.tsd-is-static.tsd-kind-event > .tsd-kind-icon:before { background-position: -136px -187px; } +.tsd-is-static.tsd-kind-event.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -187px; } +.tsd-is-static.tsd-kind-event.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -187px; } +.tsd-is-static.tsd-kind-event.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -187px; } +.tsd-is-static.tsd-kind-event.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -187px; } +.tsd-is-static.tsd-kind-event.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -187px; } +.tsd-is-static.tsd-kind-event.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -187px; } +.tsd-is-static.tsd-kind-event.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -187px; } +.tsd-is-static.tsd-kind-event.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -187px; } +.tsd-is-static.tsd-kind-event.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -187px; } +.tsd-is-static.tsd-kind-event.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -187px; } +.tsd-is-static.tsd-kind-event.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -187px; } +.tsd-is-static.tsd-kind-event.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -187px; } + +.no-transition { -webkit-transition: none !important; transition: none !important; } + +@-webkit-keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } } + +@keyframes fade-in { from { opacity: 0; } + to { opacity: 1; } } +@-webkit-keyframes fade-out { + from { opacity: 1; visibility: visible; } + to { opacity: 0; } } +@keyframes fade-out { from { opacity: 1; visibility: visible; } + to { opacity: 0; } } +@-webkit-keyframes fade-in-delayed { + 0% { opacity: 0; } + 33% { opacity: 0; } + 100% { opacity: 1; } } +@keyframes fade-in-delayed { 0% { opacity: 0; } + 33% { opacity: 0; } + 100% { opacity: 1; } } +@-webkit-keyframes fade-out-delayed { + 0% { opacity: 1; visibility: visible; } + 66% { opacity: 0; } + 100% { opacity: 0; } } +@keyframes fade-out-delayed { 0% { opacity: 1; visibility: visible; } + 66% { opacity: 0; } + 100% { opacity: 0; } } +@-webkit-keyframes shift-to-left { + from { -webkit-transform: translate(0, 0); transform: translate(0, 0); } + to { -webkit-transform: translate(-25%, 0); transform: translate(-25%, 0); } } +@keyframes shift-to-left { from { -webkit-transform: translate(0, 0); transform: translate(0, 0); } + to { -webkit-transform: translate(-25%, 0); transform: translate(-25%, 0); } } +@-webkit-keyframes unshift-to-left { + from { -webkit-transform: translate(-25%, 0); transform: translate(-25%, 0); } + to { -webkit-transform: translate(0, 0); transform: translate(0, 0); } } +@keyframes unshift-to-left { from { -webkit-transform: translate(-25%, 0); transform: translate(-25%, 0); } + to { -webkit-transform: translate(0, 0); transform: translate(0, 0); } } +@-webkit-keyframes pop-in-from-right { + from { -webkit-transform: translate(100%, 0); transform: translate(100%, 0); } + to { -webkit-transform: translate(0, 0); transform: translate(0, 0); } } +@keyframes pop-in-from-right { from { -webkit-transform: translate(100%, 0); transform: translate(100%, 0); } + to { -webkit-transform: translate(0, 0); transform: translate(0, 0); } } +@-webkit-keyframes pop-out-to-right { + from { -webkit-transform: translate(0, 0); transform: translate(0, 0); visibility: visible; } + to { -webkit-transform: translate(100%, 0); transform: translate(100%, 0); } } +@keyframes pop-out-to-right { from { -webkit-transform: translate(0, 0); transform: translate(0, 0); visibility: visible; } + to { -webkit-transform: translate(100%, 0); transform: translate(100%, 0); } } + +.tsd-typography { line-height: 1.333em; } +.tsd-typography ul { list-style: square; padding: 0 0 0 20px; margin: 0; } +.tsd-typography h4, .tsd-typography .tsd-index-panel h3, .tsd-index-panel .tsd-typography h3, .tsd-typography h5, .tsd-typography h6 { font-size: 1em; margin: 0; } +.tsd-typography h5, .tsd-typography h6 { font-weight: normal; } +.tsd-typography p, .tsd-typography ul, .tsd-typography ol { margin: 1em 0; } + +@media (min-width: 901px) and (max-width: 1024px) { html.default .col-content { width: 72%; } + html.default .col-menu { width: 28%; } + html.default .tsd-navigation { padding-left: 10px; } } +@media (max-width: 900px) { html.default .col-content { float: none; width: 100%; } + html.default .col-menu { position: fixed !important; overflow: auto; -webkit-overflow-scrolling: touch; overflow-scrolling: touch; z-index: 1024; top: 0 !important; bottom: 0 !important; left: auto !important; right: 0 !important; width: 100%; padding: 20px 20px 0 0; max-width: 450px; visibility: hidden; background-color: #fff; -webkit-transform: translate(100%, 0); -ms-transform: translate(100%, 0); transform: translate(100%, 0); } + html.default .col-menu > *:last-child { padding-bottom: 20px; } + html.default .overlay { content: ""; display: block; position: fixed; z-index: 1023; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.75); visibility: hidden; } + html.default.to-has-menu .overlay { -webkit-animation: fade-in 0.4s; animation: fade-in 0.4s; } + html.default.to-has-menu header, html.default.to-has-menu footer, html.default.to-has-menu .col-content { -webkit-animation: shift-to-left 0.4s; animation: shift-to-left 0.4s; } + html.default.to-has-menu .col-menu { -webkit-animation: pop-in-from-right 0.4s; animation: pop-in-from-right 0.4s; } + html.default.from-has-menu .overlay { -webkit-animation: fade-out 0.4s; animation: fade-out 0.4s; } + html.default.from-has-menu header, html.default.from-has-menu footer, html.default.from-has-menu .col-content { -webkit-animation: unshift-to-left 0.4s; animation: unshift-to-left 0.4s; } + html.default.from-has-menu .col-menu { -webkit-animation: pop-out-to-right 0.4s; animation: pop-out-to-right 0.4s; } + html.default.has-menu body { overflow: hidden; } + html.default.has-menu .overlay { visibility: visible; } + html.default.has-menu header, html.default.has-menu footer, html.default.has-menu .col-content { -webkit-transform: translate(-25%, 0); -ms-transform: translate(-25%, 0); transform: translate(-25%, 0); } + html.default.has-menu .col-menu { visibility: visible; -webkit-transform: translate(0, 0); -ms-transform: translate(0, 0); transform: translate(0, 0); } } + +.tsd-page-title { padding: 70px 0 20px 0; margin: 0 0 40px 0; background: #fff; box-shadow: 0 0 5px rgba(0, 0, 0, 0.35); } +.tsd-page-title h1 { margin: 0; } + +.tsd-breadcrumb { margin: 0; padding: 0; color: #808080; } +.tsd-breadcrumb a { color: #808080; text-decoration: none; } +.tsd-breadcrumb a:hover { text-decoration: underline; } +.tsd-breadcrumb li { display: inline; } +.tsd-breadcrumb li:after { content: " / "; } + +html.minimal .container-main { padding-bottom: 0; } +html.minimal .content-wrap { padding-left: 340px; } +html.minimal .tsd-navigation { position: fixed !important; float: left; overflow: auto; -webkit-overflow-scrolling: touch; overflow-scrolling: touch; box-sizing: border-box; z-index: 1; top: 60px; bottom: 0; width: 300px; padding: 20px; margin: 0; } +html.minimal .tsd-member .tsd-member { margin-left: 0; } +html.minimal .tsd-page-toolbar { position: fixed; z-index: 2; } +html.minimal #tsd-filter .tsd-filter-group { right: 0; -webkit-transform: none; -ms-transform: none; transform: none; } +html.minimal footer { background-color: transparent; } +html.minimal footer .container { padding: 0; } +html.minimal .tsd-generator { padding: 0; } +@media (max-width: 900px) { html.minimal .tsd-navigation { display: none; } + html.minimal .content-wrap { padding-left: 0; } } + +dl.tsd-comment-tags { overflow: hidden; } +dl.tsd-comment-tags dt { clear: both; float: left; padding: 1px 5px; margin: 0 10px 0 0; border-radius: 4px; border: 1px solid #808080; color: #808080; font-size: 0.8em; font-weight: normal; } +dl.tsd-comment-tags dd { margin: 0 0 10px 0; } +dl.tsd-comment-tags p { margin: 0; } + +.tsd-panel.tsd-comment .lead { font-size: 1.1em; line-height: 1.333em; margin-bottom: 2em; } +.tsd-panel.tsd-comment .lead:last-child { margin-bottom: 0; } + +.toggle-protected .tsd-is-private { display: none; } + +.toggle-public .tsd-is-private, .toggle-public .tsd-is-protected, .toggle-public .tsd-is-private-protected { display: none; } + +.toggle-inherited .tsd-is-inherited { display: none; } + +.toggle-only-exported .tsd-is-not-exported { display: none; } + +.toggle-externals .tsd-is-external { display: none; } + +#tsd-filter { position: relative; display: inline-block; height: 40px; vertical-align: bottom; } +.no-filter #tsd-filter { display: none; } +#tsd-filter .tsd-filter-group { display: inline-block; height: 40px; vertical-align: bottom; white-space: nowrap; } +#tsd-filter input { display: none; } +@media (max-width: 900px) { #tsd-filter .tsd-filter-group { display: block; position: absolute; top: 40px; right: 20px; height: auto; background-color: #fff; visibility: hidden; -webkit-transform: translate(50%, 0); -ms-transform: translate(50%, 0); transform: translate(50%, 0); box-shadow: 0 0 4px rgba(0, 0, 0, 0.25); } + .has-options #tsd-filter .tsd-filter-group { visibility: visible; } + .to-has-options #tsd-filter .tsd-filter-group { -webkit-animation: fade-in 0.2s; animation: fade-in 0.2s; } + .from-has-options #tsd-filter .tsd-filter-group { -webkit-animation: fade-out 0.2s; animation: fade-out 0.2s; } + #tsd-filter label, #tsd-filter .tsd-select { display: block; padding-right: 20px; } } + +footer { background-color: #fff; } +footer.with-border-bottom { border-bottom: 1px solid #eee; margin-left: 20px } +footer .tsd-legend-group { font-size: 0; } +footer .tsd-legend { display: inline-block; width: 25%; padding: 0; font-size: 16px; list-style: none; line-height: 1.333em; vertical-align: top; } +@media (max-width: 900px) { footer .tsd-legend { width: 50%; } } + +.tsd-hierarchy { list-style: square; padding: 0 0 0 20px; margin: 0; } +.tsd-hierarchy .target { font-weight: bold; } + +.tsd-index-panel .tsd-index-content { margin-bottom: -30px !important; } +.tsd-index-panel .tsd-index-section { margin-bottom: 30px !important; } +.tsd-index-panel h3 { margin: 0 -20px 10px -20px; padding: 0 20px 10px 20px; border-bottom: 1px solid #eee; } +.tsd-index-panel ul.tsd-index-list { -webkit-column-count: 3; -moz-column-count: 3; -ms-column-count: 3; column-count: 3; -webkit-column-gap: 20px; -moz-column-gap: 20px; -ms-column-gap: 20px; column-gap: 20px; padding: 0; list-style: none; line-height: 1.333em; } +@media (max-width: 900px) { .tsd-index-panel ul.tsd-index-list { -webkit-column-count: 1; -moz-column-count: 1; -ms-column-count: 1; column-count: 1; } } +@media (min-width: 901px) and (max-width: 1024px) { .tsd-index-panel ul.tsd-index-list { -webkit-column-count: 2; -moz-column-count: 2; -ms-column-count: 2; column-count: 2; } } +.tsd-index-panel ul.tsd-index-list li { -webkit-column-break-inside: avoid; -moz-column-break-inside: avoid; -ms-column-break-inside: avoid; -o-column-break-inside: avoid; column-break-inside: avoid; -webkit-page-break-inside: avoid; -moz-page-break-inside: avoid; -ms-page-break-inside: avoid; -o-page-break-inside: avoid; page-break-inside: avoid; } +.tsd-index-panel a, .tsd-index-panel .tsd-parent-kind-module a { color: #9600ff; } +.tsd-index-panel .tsd-parent-kind-interface a { color: #7da01f; } +.tsd-index-panel .tsd-parent-kind-enum a { color: #cc9900; } +.tsd-index-panel .tsd-parent-kind-class a { color: #4da6ff; } +.tsd-index-panel .tsd-kind-module a { color: #9600ff; } +.tsd-index-panel .tsd-kind-interface a { color: #7da01f; } +.tsd-index-panel .tsd-kind-enum a { color: #cc9900; } +.tsd-index-panel .tsd-kind-class a { color: #4da6ff; } +.tsd-index-panel .tsd-is-private a { color: #808080; } + +.tsd-flag { display: inline-block; padding: 1px 5px; border-radius: 4px; color: #fff; background-color: #808080; text-indent: 0; font-size: 14px; font-weight: normal; } + +.tsd-anchor { position: absolute; top: -100px; } + +.tsd-member { position: relative; } +.tsd-member .tsd-anchor + h3 { margin-top: 0; margin-bottom: 0; border-bottom: none; } + +.tsd-navigation { padding: 0 0 0 40px; } +.tsd-navigation a { display: block; padding-top: 2px; padding-bottom: 2px; border-left: 2px solid transparent; color: #222; text-decoration: none; -webkit-transition: border-left-color 0.1s; transition: border-left-color 0.1s; } +.tsd-navigation a:hover { text-decoration: underline; } +.tsd-navigation ul { margin: 0; padding: 0; list-style: none; } +.tsd-navigation li { padding: 0; } + +.tsd-navigation.primary { padding-bottom: 40px; } +.tsd-navigation.primary a { display: block; padding-top: 6px; padding-bottom: 6px; } +.tsd-navigation.primary ul li a { padding-left: 5px; } +.tsd-navigation.primary ul li li a { padding-left: 25px; } +.tsd-navigation.primary ul li li li a { padding-left: 45px; } +.tsd-navigation.primary ul li li li li a { padding-left: 65px; } +.tsd-navigation.primary ul li li li li li a { padding-left: 85px; } +.tsd-navigation.primary ul li li li li li li a { padding-left: 105px; } +.tsd-navigation.primary > ul { border-bottom: 1px solid #eee; } +.tsd-navigation.primary li { border-top: 1px solid #eee; } +.tsd-navigation.primary li.current > a { font-weight: bold; } +.tsd-navigation.primary li.label span { display: block; padding: 20px 0 6px 5px; color: #808080; } +.tsd-navigation.primary li.globals + li > span, .tsd-navigation.primary li.globals + li > a { padding-top: 20px; } + +.tsd-navigation.secondary ul { -webkit-transition: opacity 0.2s; transition: opacity 0.2s; } +.tsd-navigation.secondary ul li a { padding-left: 25px; } +.tsd-navigation.secondary ul li li a { padding-left: 45px; } +.tsd-navigation.secondary ul li li li a { padding-left: 65px; } +.tsd-navigation.secondary ul li li li li a { padding-left: 85px; } +.tsd-navigation.secondary ul li li li li li a { padding-left: 105px; } +.tsd-navigation.secondary ul li li li li li li a { padding-left: 125px; } +.tsd-navigation.secondary ul.current a { border-left-color: #eee; } +.tsd-navigation.secondary li.focus > a, .tsd-navigation.secondary ul.current li.focus > a { border-left-color: #000; } +.tsd-navigation.secondary li.current { margin-top: 20px; margin-bottom: 20px; border-left-color: #eee; } +.tsd-navigation.secondary li.current > a { font-weight: bold; } + +@media (min-width: 901px) { .menu-sticky-wrap { position: static; } + .no-csspositionsticky .menu-sticky-wrap.sticky { position: fixed; } + .no-csspositionsticky .menu-sticky-wrap.sticky-current { position: fixed; } + .no-csspositionsticky .menu-sticky-wrap.sticky-current ul.before-current, .no-csspositionsticky .menu-sticky-wrap.sticky-current ul.after-current { opacity: 0; } + .no-csspositionsticky .menu-sticky-wrap.sticky-bottom { position: absolute; top: auto !important; left: auto !important; bottom: 0; right: 0; } + .csspositionsticky .menu-sticky-wrap.sticky { position: -webkit-sticky; position: sticky; } + .csspositionsticky .menu-sticky-wrap.sticky-current { position: -webkit-sticky; position: sticky; } } + +.tsd-panel { margin: 20px 0; padding: 20px; background-color: #fff; box-shadow: 0 0 4px rgba(0, 0, 0, 0.25); } +.tsd-panel:empty { display: none; } +.tsd-panel > h1, .tsd-panel > h2, .tsd-panel > h3 { margin: 1.5em -20px 10px -20px; padding: 0 20px 10px 20px; border-bottom: 1px solid #eee; } +.tsd-panel > h1.tsd-before-signature, .tsd-panel > h2.tsd-before-signature, .tsd-panel > h3.tsd-before-signature { margin-bottom: 0; border-bottom: 0; } +.tsd-panel table { display: block; width: 100%; overflow: auto; margin-top: 10px; word-break: normal; word-break: keep-all; } +.tsd-panel table th { font-weight: bold; } +.tsd-panel table th, .tsd-panel table td { padding: 6px 13px; border: 1px solid #ddd; } +.tsd-panel table tr { background-color: #fff; border-top: 1px solid #ccc; } +.tsd-panel table tr:nth-child(2n) { background-color: #f8f8f8; } + +.tsd-panel-group { margin: 30px 0; } +.tsd-panel-group > h1, .tsd-panel-group > h2, .tsd-panel-group > h3 { padding-left: 20px; padding-right: 20px; } + +#tsd-search { -webkit-transition: background-color 0.2s; transition: background-color 0.2s; } +#tsd-search .title { position: relative; z-index: 2; } +#tsd-search .field { position: absolute; left: 0; top: 0; right: 40px; height: 40px; } +#tsd-search .field input { box-sizing: border-box; position: relative; top: -50px; z-index: 1; width: 100%; padding: 0 10px; opacity: 0; outline: 0; border: 0; background: transparent; color: #222; } +#tsd-search .field label { position: absolute; overflow: hidden; right: -40px; } +#tsd-search .field input, #tsd-search .title { -webkit-transition: opacity 0.2s; transition: opacity 0.2s; } +#tsd-search .results { position: absolute; visibility: hidden; top: 40px; width: 100%; margin: 0; padding: 0; list-style: none; box-shadow: 0 0 4px rgba(0, 0, 0, 0.25); } +#tsd-search .results li { padding: 0 10px; background-color: #fdfdfd; } +#tsd-search .results li:nth-child(even) { background-color: #fff; } +#tsd-search .results li.state { display: none; } +#tsd-search .results li.current, #tsd-search .results li:hover { background-color: #eee; } +#tsd-search .results a { display: block; } +#tsd-search .results a:before { top: 10px; } +#tsd-search .results span.parent { color: #808080; font-weight: normal; } +#tsd-search.has-focus { background-color: #eee; } +#tsd-search.has-focus .field input { top: 0; opacity: 1; } +#tsd-search.has-focus .title { z-index: 0; opacity: 0; } +#tsd-search.has-focus .results { visibility: visible; } +#tsd-search.loading .results li.state.loading { display: block; } +#tsd-search.failure .results li.state.failure { display: block; } + +.tsd-signature { margin: 0 0 1em 0; padding: 10px; border: 1px solid #eee; font-family: Menlo, Monaco, Consolas, "Courier New", monospace; font-size: 14px; } +.tsd-signature.tsd-kind-icon { padding-left: 30px; } +.tsd-signature.tsd-kind-icon:before { top: 10px; left: 10px; } +.tsd-panel > .tsd-signature { margin-left: -20px; margin-right: -20px; border-width: 1px 0; } +.tsd-panel > .tsd-signature.tsd-kind-icon { padding-left: 40px; } +.tsd-panel > .tsd-signature.tsd-kind-icon:before { left: 20px; } + +.tsd-signature-symbol { color: #808080; font-weight: normal; } + +.tsd-signature-type { font-style: italic; font-weight: normal; } + +.tsd-signatures { padding: 0; margin: 0 0 1em 0; border: 1px solid #eee; } +.tsd-signatures .tsd-signature { margin: 0; border-width: 1px 0 0 0; -webkit-transition: background-color 0.1s; transition: background-color 0.1s; } +.tsd-signatures .tsd-signature:first-child { border-top-width: 0; } +.tsd-signatures .tsd-signature.current { background-color: #eee; } +.tsd-signatures.active > .tsd-signature { cursor: pointer; } +.tsd-panel > .tsd-signatures { margin-left: -20px; margin-right: -20px; border-width: 1px 0; } +.tsd-panel > .tsd-signatures .tsd-signature.tsd-kind-icon { padding-left: 40px; } +.tsd-panel > .tsd-signatures .tsd-signature.tsd-kind-icon:before { left: 20px; } +.tsd-panel > a.anchor + .tsd-signatures { border-top-width: 0; margin-top: -20px; } + +ul.tsd-descriptions { position: relative; overflow: hidden; -webkit-transition: height 0.3s; transition: height 0.3s; padding: 0; list-style: none; } +ul.tsd-descriptions.active > .tsd-description { display: none; } +ul.tsd-descriptions.active > .tsd-description.current { display: block; } +ul.tsd-descriptions.active > .tsd-description.fade-in { -webkit-animation: fade-in-delayed 0.3s; animation: fade-in-delayed 0.3s; } +ul.tsd-descriptions.active > .tsd-description.fade-out { -webkit-animation: fade-out-delayed 0.3s; animation: fade-out-delayed 0.3s; position: absolute; display: block; top: 0; left: 0; right: 0; opacity: 0; visibility: hidden; } +ul.tsd-descriptions h4, ul.tsd-descriptions .tsd-index-panel h3, .tsd-index-panel ul.tsd-descriptions h3 { font-size: 16px; margin: 1em 0 0.5em 0; } + +ul.tsd-parameters, ul.tsd-type-parameters { list-style: square; margin: 0; padding-left: 20px; } +ul.tsd-parameters > li.tsd-parameter-siganture, ul.tsd-type-parameters > li.tsd-parameter-siganture { list-style: none; margin-left: -20px; } +ul.tsd-parameters h5, ul.tsd-type-parameters h5 { font-size: 16px; margin: 1em 0 0.5em 0; } +ul.tsd-parameters .tsd-comment, ul.tsd-type-parameters .tsd-comment { margin-top: -0.5em; } + +.tsd-sources { font-size: 14px; color: #808080; margin: 0 0 1em 0; } +.tsd-sources a { color: #808080; text-decoration: underline; } +.tsd-sources ul, .tsd-sources p { margin: 0 !important; } +.tsd-sources ul { list-style: none; padding: 0; } + +.tsd-page-toolbar { position: absolute; z-index: 1; top: 0; left: 0; width: 100%; height: 40px; color: #333; background: #fff; border-bottom: 1px solid #eee; } +.tsd-page-toolbar a { color: #333; text-decoration: none; } +.tsd-page-toolbar a.title { font-weight: bold; } +.tsd-page-toolbar a.title:hover { text-decoration: underline; } +.tsd-page-toolbar .table-wrap { display: table; width: 100%; height: 40px; } +.tsd-page-toolbar .table-cell { display: table-cell; position: relative; white-space: nowrap; line-height: 40px; } +.tsd-page-toolbar .table-cell:first-child { width: 100%; } + +.tsd-widget:before, .tsd-select .tsd-select-label:before, .tsd-select .tsd-select-list li:before { content: ""; display: inline-block; width: 40px; height: 40px; margin: 0 -8px 0 0; background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAUAAAAAoCAQAAAAlSeuiAAABp0lEQVR4Ae3aUa3jQAyF4QNhIBTCQiiEQlgIhRAGhTAQBkIgBEIgDITZZGXNjZTePiSWYqn/54dGfbAq+SiTutWXAgAAAAAAAAAAAAA8NCz1UFSD2lKDS5d3NVzZj/BVNasaLoRZRUmj2lLrVVHWMUntQ13Wj/i1pWa9lprX6xMRnH4dx6Rjsn26+v+12ms+EcB37P0r+qH+DNQGXgMFcHzbregQ78B8eQCTJk0e979ZW7PdA2O49ceDsYexKgUNoI3EKYDWL3D8miaPh/uXtl6BHqEHFQvgXau/FsCiIWAAbST2fpQRT0sl70j3z5ZiBdD7CG5WZX8kxwmgjbiP5GQA9/3O2XaxnnHi53AEE0AbRh+JQwC3/fzC4hcb6xPvS4i3QaMdwX+0utsRPEY6gm2wNhKHAG77eUi7SIcK4G4NY4GMIan2u2Cxqzncl5DUn7Q8ArjvZ8JFOsl/Ed0jyBom+BomQKSto+9PcblHMM4iuu4X0QQw5hrGQY/gUxFkjZuf4m4alXVU+1De/VhEn5CvDSB/RsBzqWgAAAAAAAAAAAAAAACAfyyYJ5nhVuwIAAAAAElFTkSuQmCC); background-repeat: no-repeat; text-indent: -1024px; vertical-align: bottom; } +@media (-webkit-min-device-pixel-ratio: 1.5), (min-device-pixel-ratio: 1.5), (min-resolution: 144dpi) { .tsd-widget:before, .tsd-select .tsd-select-label:before, .tsd-select .tsd-select-list li:before { background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAoAAAABQCAMAAAC+sjQXAAAAM1BMVEUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACjBUbJAAAAEXRSTlMA3/+/UCBw7xCPYIBAMM+vn1qYQ7QAAALCSURBVHgB7MGBAAAAAICg/akXqQIAAAAAAAAAAAAAAAAAAJids9mdE4bhoDNZCITP93/aSmhV/9uwPWyi8jtkblws2IxsYpz9LwSAaJW8AreE16PxOsMYE6Q4DiYKF7X+8ZHXc/E608xv5snEyIuZrVwMZjbnujR6T3gsXmcLOIRNzD+Ig2UuVtt2+NbAiX/wVLzOlviD9L2BOfGBlL/3D1I+uDjGBJArBPxU3x+K15kCQFo2s21JAOHrKpz4SPrWv4IKA+uFaR6vMwMcb+emA2DWEfDglrkLqEBOKVslA8Dx14oPMiV4CtywWxdQgAwkq2QE0uTXUwJGk2G9s3mTFNBzAkC7HKPsX72AEVjMnAWIpsPCRRjXdQxcjCYpoOcEgHY5Rtk/slWSgM3M2aSeeVgjAOeVpKcdgGMdNAXMuIAqOcZzqF8L+WcAsi8wkTeheCWMegL6mgCorHHyEJ5TVfxrLWDrTUjZdhnhjYqAnlN8TaoELOLVC0gucmoz/3RKcPs2jAs4+J5ET8AEZF+TSgGLeC1V8YuGQQU2IV1Asq9JCwE9XitZVPxr34bpJRj8PqsFLOK108W9aVrWZRrR7Sm2HL4JCToCujHZ6gUs4jUz0P1TEvD+U5wMa363YeziBODIq1YbJrsv9QKW8Ry1nNp+GAHvuingRTfmYcjBf0QpAS37bdUL6PFKtHJq63EsZ5cxcKMkDVIClu1dAK1PcJ5TFQ0M9wZKDCPs3BD7MIJGTs3WfiTfDVQYx5q5ZekCauTU3P5Q0ukGCgh49oFURdobWBY9N/CxEuwGjpGLuPhTdwH1x7HqDDxNgRP2zQ8lraFyF/yJ9vH6QGqtgSbBOU8/j2VORz+Wqfle2d5Ae4R+ML0z7Y+W4P7XHN3AU+tzyK/24EAGAAAAYJC/9T2+CgAAAAAAAAAAAAAAAAAAAADgJpfzHyIKFFBKAAAAAElFTkSuQmCC); background-size: 320px 40px; } } + +.tsd-widget { display: inline-block; overflow: hidden; opacity: 0.6; height: 40px; -webkit-transition: opacity 0.1s, background-color 0.2s; transition: opacity 0.1s, background-color 0.2s; vertical-align: bottom; cursor: pointer; } +.tsd-widget:hover { opacity: 0.8; } +.tsd-widget.active { opacity: 1; background-color: #eee; } +.tsd-widget.no-caption { width: 40px; } +.tsd-widget.no-caption:before { margin: 0; } +.tsd-widget.search:before { background-position: 0 0; } +.tsd-widget.menu:before { background-position: -40px 0; } +.tsd-widget.options:before { background-position: -80px 0; } +.tsd-widget.options, .tsd-widget.menu { display: none; } +@media (max-width: 900px) { .tsd-widget.options, .tsd-widget.menu { display: inline-block; } } +input[type=checkbox] + .tsd-widget:before { background-position: -120px 0; } +input[type=checkbox]:checked + .tsd-widget:before { background-position: -160px 0; } + +.tsd-select { position: relative; display: inline-block; height: 40px; -webkit-transition: opacity 0.1s, background-color 0.2s; transition: opacity 0.1s, background-color 0.2s; vertical-align: bottom; cursor: pointer; } +.tsd-select .tsd-select-label { opacity: 0.6; -webkit-transition: opacity 0.2s; transition: opacity 0.2s; } +.tsd-select .tsd-select-label:before { background-position: -240px 0; } +.tsd-select.active .tsd-select-label { opacity: 0.8; } +.tsd-select.active .tsd-select-list { visibility: visible; opacity: 1; -webkit-transition-delay: 0s; transition-delay: 0s; } +.tsd-select .tsd-select-list { position: absolute; visibility: hidden; top: 40px; left: 0; margin: 0; padding: 0; opacity: 0; list-style: none; box-shadow: 0 0 4px rgba(0, 0, 0, 0.25); -webkit-transition: visibility 0s 0.2s, opacity 0.2s; transition: visibility 0s 0.2s, opacity 0.2s; } +.tsd-select .tsd-select-list li { padding: 0 20px 0 0; background-color: #fdfdfd; } +.tsd-select .tsd-select-list li:before { background-position: 40px 0; } +.tsd-select .tsd-select-list li:nth-child(even) { background-color: #fff; } +.tsd-select .tsd-select-list li:hover { background-color: #eee; } +.tsd-select .tsd-select-list li.selected:before { background-position: -200px 0; } +@media (max-width: 900px) { .tsd-select .tsd-select-list { top: 0; left: auto; right: 100%; margin-right: -5px; } + .tsd-select .tsd-select-label:before { background-position: -280px 0; } } diff --git a/docs/css/extra.css b/docs/css/extra.css new file mode 100644 index 000000000..804bb597f --- /dev/null +++ b/docs/css/extra.css @@ -0,0 +1,14 @@ +pre code { + white-space: pre; + word-wrap: normal; + display: block; + padding: 12px; + font-size: 14px; +} + +code { + white-space: pre-wrap; + word-wrap: break-word; + padding: 2px 5px; + font-size: 16px; +} diff --git a/docs/css/github-highlight.css b/docs/css/github-highlight.css new file mode 100644 index 000000000..52b18879c --- /dev/null +++ b/docs/css/github-highlight.css @@ -0,0 +1,224 @@ +pre { + border: none !important; + background-color: #fff !important; +} +code { + color: inherit !important; + background-color: #fff; +} +pre, code { + white-space: pre !important; +} +.highlight table pre { margin: 0; } +.highlight .cm { + color: #999988; + font-style: italic; +} +.highlight .cp { + color: #999999; + font-weight: bold; +} +.highlight .c1 { + color: #999988; + font-style: italic; +} +.highlight .cs { + color: #999999; + font-weight: bold; + font-style: italic; +} +.highlight .c, .highlight .cd { + color: #999988; + font-style: italic; +} +.highlight .err { + color: #a61717; + background-color: #e3d2d2; +} +.highlight .gd { + color: #000000; + background-color: #ffdddd; +} +.highlight .ge { + color: #000000; + font-style: italic; +} +.highlight .gr { + color: #aa0000; +} +.highlight .gh { + color: #999999; +} +.highlight .gi { + color: #000000; + background-color: #ddffdd; +} +.highlight .go { + color: #888888; +} +.highlight .gp { + color: #555555; +} +.highlight .gs { + font-weight: bold; +} +.highlight .gu { + color: #aaaaaa; +} +.highlight .gt { + color: #aa0000; +} +.highlight .kc { + color: #000000; + font-weight: bold; +} +.highlight .kd { + color: #000000; + font-weight: bold; +} +.highlight .kn { + color: #000000; + font-weight: bold; +} +.highlight .kp { + color: #000000; + font-weight: bold; +} +.highlight .kr { + color: #000000; + font-weight: bold; +} +.highlight .kt { + color: #445588; + font-weight: bold; +} +.highlight .k, .highlight .kv { + color: #000000; + font-weight: bold; +} +.highlight .mf { + color: #009999; +} +.highlight .mh { + color: #009999; +} +.highlight .il { + color: #009999; +} +.highlight .mi { + color: #009999; +} +.highlight .mo { + color: #009999; +} +.highlight .m, .highlight .mb, .highlight .mx { + color: #009999; +} +.highlight .sb { + color: #d14; +} +.highlight .sc { + color: #d14; +} +.highlight .sd { + color: #d14; +} +.highlight .s2 { + color: #d14; +} +.highlight .se { + color: #d14; +} +.highlight .sh { + color: #d14; +} +.highlight .si { + color: #d14; +} +.highlight .sx { + color: #d14; +} +.highlight .sr { + color: #009926; +} +.highlight .s1 { + color: #d14; +} +.highlight .ss { + color: #990073; +} +.highlight .s { + color: #d14; +} +.highlight .na { + color: #008080; +} +.highlight .bp { + color: #999999; +} +.highlight .nb { + color: #0086B3; +} +.highlight .nc { + color: #445588; + font-weight: bold; +} +.highlight .no { + color: #008080; +} +.highlight .nd { + color: #3c5d5d; + font-weight: bold; +} +.highlight .ni { + color: #800080; +} +.highlight .ne { + color: #990000; + font-weight: bold; +} +.highlight .nf { + color: #990000; + font-weight: bold; +} +.highlight .nl { + color: #990000; + font-weight: bold; +} +.highlight .nn { + color: #555555; +} +.highlight .nt { + color: #000080; +} +.highlight .vc { + color: #008080; +} +.highlight .vg { + color: #008080; +} +.highlight .vi { + color: #008080; +} +.highlight .nv { + color: #008080; +} +.highlight .ow { + color: #000000; + font-weight: bold; +} +.highlight .o { + color: #000000; + font-weight: bold; +} +.highlight .w { + color: #bbbbbb; +} +.highlight { + background-color: #fff; + padding-bottom: 0px; +} +.highlighter-rouge { + border: 1px solid #ccc; + margin-bottom: 1em; +} diff --git a/docs/css/main.css b/docs/css/main.css new file mode 100644 index 000000000..05fd446ec --- /dev/null +++ b/docs/css/main.css @@ -0,0 +1,276 @@ +/* General CSS */ +@import url(https://fonts.googleapis.com/css?family=Open+Sans); + +body { + font-family: "Open Sans", Helvetica, Arial, sans-serif; +} + +img { + max-width: 90%; +} + +/* Custom CSS for home page */ +.hero-spacer { + margin-top: 50px; +} + +.hero-feature { + margin-bottom: 30px; +} + +.blog-content-wrap { + margin-top: 20px; + padding-top: 40px; +} + +/* Custom CSS for the docs */ + +.edit-links { + color: #cccccc; +} + +/* Prevent in-page links from scrolling under top nav */ +h2::before, h3::before { + display: block; + content: " "; + margin-top: -80px; + height: 80px; + visibility: hidden; +} + +/* Custom CSS for header and footer */ +.site-header +{ + width: 100%; + padding: 24px 0px; +} + +img.logo +{ + height: 28px; + margin-left: -18px; +} + +.icon > svg +{ + display: inline-block; + width: 28px; + height: 28px; +} + +.icon > svg path +{ + fill: #333333; +} + + +.thumb-home { + height: 340px; +} + +.img-home { + height: 150px !important; + margin: 10px; +} + +.tableauIcon { + margin-left: 18px; +} + +.jumbotron { + background-color: #fafafa; + border: 1px solid #e8e8e8; +} + +#community-jumbo { + background-color: #F0F8FF; +} + +footer { + margin: 30px 0; + text-align: center; +} + +.footer-hr { + width: 100%; + position: relative; +} + +table { + border: 1px solid #c2c2c2; + border-collapse: collapse; + margin: 1em 0px; +} + +th +{ + font-weight: bold; + border: 1px solid #c2c2c2; + text-align: left; + padding: .3em; + vertical-align: top; + background-color: #fafafa; +} + +td +{ + border: 1px solid #c2c2c2; + text-align: left; + padding: .3em; + vertical-align: top; +} + +/* So the scroll bar width doesn't cause the page to jump */ +html { + overflow-y: scroll; +} + +.label { + margin-right: 3px; +} + +/* to get right navbar icons to respect collapse */ +@media (max-width: 992px) { + .navbar-header { + float: none; + } + .navbar-left,.navbar-right { + float: none !important; + } + .navbar-toggle { + display: block; + } + .navbar-collapse { + border-top: 1px solid transparent; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.1); + } + .navbar-fixed-top { + top: 0; + border-width: 0 0 1px; + } + .navbar-collapse.collapse { + display: none!important; + } + .navbar-nav { + float: none!important; + margin-top: 7.5px; + } + .navbar-nav>li { + float: none; + } + .navbar-nav>li>a { + padding-top: 10px; + padding-bottom: 10px; + } + .collapse.in{ + display:block !important; + } +} + + +/* Custom css for news section */ +.blog-content { + margin-bottom: 70px; +} + +.blogul { + padding: 0px; +} + +.blogul h1 { + margin-top: 40px; +} + +.blog-content > h4 { + margin-top: 20px; +} + +.blog-content > ol { + margin-bottom: 20px; +} + + +/* Community connectors */ +.thumbnail { + background-color: #fff; + border: 1px solid #ccc; + margin: 12px; +} + +.thumbnail h2 { + border-left: 2px solid #337ab7; + text-decoration: none; + font-size: 20px; + padding: 6px; + margin-left: 10px; +} + +.thumbnail h2 a { + text-decoration: none; + color: #333333; +} + +.well { + background-color: #ffffff; + margin-bottom: 40px; +} + +.tsd-navigation { + padding: 10px !important; +} + +/* Media queries for responsive design */ +#grid[data-columns]::before { + content: '3 .column.size-1of3'; +} + +.column { float: left; } +.size-1of3 { width: 33.333%; } + + +@media screen and (max-width: 767px) { + #grid[data-columns]::before { + content: '1 .column.size-1of1'; + } + + /* Docs Menu*/ + .docs-menu, .tsd-navigation { + top: 20px; + position: relative; + } + +} +@media screen and (min-width: 769px) { + #grid[data-columns]::before { + content: '3 .column.size-1of3'; + } + + /* Docs Menu*/ + .docs-menu, .tsd-navigation { + position: fixed; + overflow: auto; + top: 90px; + max-height: 90%; + max-width: 250px; + } + + .content { + position: relative; + margin: 40px 0px 0px 275px; + max-width: 1000px; + } + + /* API Reference */ + + .ref-content { + margin: 40px 0px 0px 275px; + max-width: 1000px; + } +} + +.column { float: left; } +.size-1of1 { width: 100%; } +.size-1of2 { width: 50%; } +.size-1of3 { width: 33.333%; } + + diff --git a/docs/docs/api-ref.md b/docs/docs/api-ref.md new file mode 100644 index 000000000..ac1d5f949 --- /dev/null +++ b/docs/docs/api-ref.md @@ -0,0 +1,261 @@ +--- +title: API reference +layout: docs +--- + +
+ Important: More coming soon! This section is under active construction and might not reflect all the available functionality of the TSC library. + Until this reference is completed, we have noted the source files in the TSC library where you can get more information for individual endpoints. +
+ +* TOC +{:toc} + +## Authentication + +Source files: server/endpoint/auth_endpoint.py, models/tableau_auth.py + +### Sign In + +Signs you in to Tableau Server. + +```py +Auth.sign_in(authentication_object) +``` + +### Sign Out + +Signs you out of Tableau Server. + +```py +Auth.sign_out() +``` + +## Sites + +Source files: server/endpoint/sites_endpoint.py, models/site_item.py + +### Create Site + +Creates a new site for the given site item object. + +```py +Sites.create(new_site_item) +``` + +Example: + +```py +new_site = TSC.SiteItem(name='Tableau', content_url='tableau', admin_mode=TSC.SiteItem.AdminMode.ContentAndUsers, user_quota=15, storage_quota=1000, disable_subscriptions=True) +self.server.sites.create(new_site) +``` + +### Get Site by ID + +Gets the site with the given ID. + +```py +Sites.get_by_id(id) +``` + +### Get Sites + +Gets the first 100 sites on the server. To get all the sites, use the Pager. + +```py +Sites.get() +``` + +### Update Site + +Modifies a site. The site item object must include the site ID and overrides all other settings. + +```py +Sites.update(site_item_object) +``` + +### Delete Site + +Deletes the site with the given ID. + +```py +Sites.delete(id) +``` + +## Projects + +Source files: server/endpoint/projects_endpoint.py + +### Create Project + +Creates a project for the given project item object. + +```py +Projects.create(project_item_object) +``` + +Example: + +```py +new_project = TSC.ProjectItem(name='Test Project', description='Project created for testing') +new_project.content_permissions = 'ManagedByOwner' +self.server.projects.create(new_project) +``` + +### Get Projects + +Get the first 100 projects on the server. To get all projects, use the Pager. + +```py +Projects.get() +``` + +### Update Project + +Modifies a project. The project item object must include the project ID and overrides all other settings. + +```py +Projects.update(project_item_object) +``` + +### Delete Project + +Deletes a project by ID. + +```py +Projects.delete(id) +``` + +## Workbooks + +Source files: server/endpoint/workbooks.py, models/workbook_item.py + +### Get Workbooks + +Get the first 100 workbooks on the server. To get all workbooks, use the Pager. + +```py +Workbooks.get() +``` + +### Get Workbook by ID + +Gets a workbook with a given ID. + +```py +Workbooks.get_by_id(id) +``` + +### Publish Workbook + +Publish a local workbook to Tableau Server. + +```py +Workbooks.publish(workbook_item, file_path, publish_mode) +``` + +Where the publish mode is one of the following: + +* Append +* Overwrite +* CreateNew + +Example: + +```py +wb_item = TSC.WorkbookItem(name='Sample', + show_tabs=False, + project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + +server.workbooks.publish(wb_item, + os.path.join(YOUR_DIR, 'SampleWB.twbx'), + self.server.PublishMode.CreateNew) +``` + +### Update Workbook + +Modifies a workbook. The workbook item object must include the workbook ID and overrides all other settings. + +```py +Workbooks.update(wb_item_object) +``` + +### Delete Workbook + +Deletes a workbook with the given ID. + +```py +Workbooks.delete(id) +``` + +### Download Workbook + +Downloads a workbook to the specified directory. + +```py +Workbooks.download(id, file_path) +``` + +### Populate Views for a Workbook + +Populates a list of views for a workbook object. You must populate views before you can iterate through the views. + +```py +Workbooks.populate_views(workbook_obj) +``` + +### Populate Connections for a Workbook + +Populates a list of connections for a given workbook. You must populate connections before you can iterate through the +connections. + +```py +Workbooks.populate_connections(workbook_obj) +``` + +### Populate a Preview Image for a Workbook + +Populates a preview image for a given workbook. You must populate the image before you can iterate through the +connections. + +```py +Workbooks.populate_connections(workbook_obj) +``` + +### Get Views for a Workbook + +Returns a list of views for a workbook. Before you get views, you must call populate_views. + +``` +workbook_obj.views +``` + +### Get Connections for a Workbook + +Returns a list of connections for a workbook. Before you get connections, you must call populate_connections. + +``` +workbook_obj.connections +``` + + + +## Views + +Source files: server/endpoint/views_endpoint.py, models/view_item.py + + + +## Data sources + +Source files: server/endpoint/datasources_endpoint.py, models/datasource_item.py + + +## Users + +Source files: server/endpoint/users_endpoint.py, models/user_item.py + +## Groups + +Source files: server/endpoint/groups_endpoint.py, models/group_item.py, + diff --git a/docs/docs/dev-guide.md b/docs/docs/dev-guide.md new file mode 100644 index 000000000..8b47609ce --- /dev/null +++ b/docs/docs/dev-guide.md @@ -0,0 +1,96 @@ +--- +title: Developer Guide +layout: docs +--- + +This topic describes how to contribute to the Tableau Server Client (Python) project. + +* TOC +{:toc} + +## Submit your first patch + +1. Make sure you have [signed the CLA](http://tableau.github.io/#contributor-license-agreement-cla) + +1. Fork the repository. + + We follow the "Fork and Pull" model as described [here](https://help.github.com/articles/about-collaborative-development-models/). + +1. Clone your fork: + + ```shell + git clone git@github.com:/server-client-python.git + ``` + +1. Run the tests to make sure everything is peachy: + + ```shell + python setup.py test + ``` + +1. Set up the feature, fix, or documentation branch. + + It is recommended to use the format issue#-type-description (e.g. 13-fix-connection-bug) like so: + + ```shell + git checkout -b 13-feature-new-stuff + ``` + +1. Code and commit! + + Here's a quick checklist for ensuring a good pull request: + + - Only touch the minimal amount of files possible while still accomplishing the goal. + - Ensure all indentation is done as 4-spaces and your editor is set to unix line endings. + - The code matches PEP8 style guides. If you cloned the repo you can run `pycodestyle .` + - Keep commit messages clean and descriptive. + If the PR is accepted it will get 'Squashed' into a single commit before merging, the commit messages will be used to generate the Merge commit message. + +1. Add tests. + + All of our tests live under the `test/` folder in the repository. + We use `unittest` and the built-in test runner `python setup.py test`. + If a test needs a static file, like a twb/twbx, it should live under `test/assets/` + +1. Update the documentation. + + Our documentation is written in markdown and built with Jekyll on Github Pages. All of the documentation source files can be found in `docs/docs`. + + When adding a new feature or improving existing functionality we may ask that you update the documentation along with your code. + + If you are just making a PR for documentation updates (adding new docs, fixing typos, improving wording) the easiest method is to use the built in `Edit this file` in the Github UI + +1. Submit to your fork. + +1. Make a PR as described [here](https://help.github.com/articles/creating-a-pull-request-from-a-fork/) against the 'development' branch. + +1. Wait for a review and address any feedback. + While we try and stay on top of all issues and PRs it might take a few days for someone to respond. Politely pinging + the PR after a few days with no response is OK, we'll try and respond with a timeline as soon as we are able. + +1. That's it! When the PR has received :rocket:'s from members of the core team they will merge the PR + + +## Add new features + +1. Create an endpoint class for the new feature, following the structure of the other endpoints. Each endpoint usually + has `get`, `post`, `update`, and `delete` operations that require making the url, creating the XML request if necesssary, + sending the request, and creating the target item object based on the server response. + +1. Create an item class for the new feature, following the structure of the other item classes. Each item has properties + that correspond to what attributes are sent to/received from the server (refer to docs and Postman for attributes). + Some items also require constants for user input that are limited to specific strings. After making all the + properties, make the parsing method that takes the server response and creates an instances of the target item. If + the corresponding endpoint class has an update function, then parsing is broken into multiple parts (refer to another + item like workbook or datasource for example). + +1. Add testing by getting real xml responses from the server, and asserting that all properties are parsed and set + correctly. + +1. Add a sample to show users how to use the new feature. + + diff --git a/docs/docs/filter-sort.md b/docs/docs/filter-sort.md new file mode 100644 index 000000000..f63e32f19 --- /dev/null +++ b/docs/docs/filter-sort.md @@ -0,0 +1,90 @@ +--- +title: Filter and Sort +layout: docs +--- +Use the `RequestOptions` object to define filtering and sorting criteria for an endpoint, +then pass the object to your endpoint as a parameter. + +* TOC +{:toc} + + +## Available endpoints and fields + +You can use the TSC library to filter and sort the following endpoints: + +* Users +* Datasources +* Workbooks +* Views + +For the above endpoints, you can filter or sort on the following +fields: + +* CreatedAt +* LastLogin +* Name +* OwnerName +* SiteRole +* Tags +* UpdatedAt + +**Important**: Not all of the fields are available for all endpoints. For more information, see the [REST +API help](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_concepts_filtering_and_sorting.htm). + +## Filtering + +To filter on a field, you need to specify the following criteria: + +### Operator criteria + +The operator that you want to use for that field. For example, you can use the Equals operator to get everything from the endpoint that matches exactly. + +The operator can be any of the following: + +* Equals +* GreaterThan +* GreaterThanOrEqual +* LessThan +* In + +### Value criteria + +The value that you want to filter on. This can be any valid string. + +### Filtering example + +The following code displays only the workbooks where the name equals Superstore: + +```py +req_option = TSC.RequestOptions() +req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, + TSC.RequestOptions.Operator.Equals, + 'Superstore')) +matching_workbooks, pagination_item = server.workbooks.get(req_option) + +print(matching_workbooks[0].owner_id) +``` + +## Sorting + +To sort on a field, you need to specify the direction in which you want to sort. + +### Direction criteria + +This can be either `Asc` for ascending or `Desc` for descending. + +### Sorting example + +The following code sorts the workbooks in ascending order: + +```py +req_option = TSC.RequestOptions() +req_option.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, + TSC.RequestOptions.Direction.Asc)) +matching_workbooks, pagination_item = server.workbooks.get(req_option) + +for wb in matching_workbooks: + print(wb.name) +``` + diff --git a/docs/docs/index.md b/docs/docs/index.md new file mode 100644 index 000000000..9fd8b699e --- /dev/null +++ b/docs/docs/index.md @@ -0,0 +1,76 @@ +--- +title: Get Started +layout: docs +--- + +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: + +* Publish workbooks and data sources. +* Create users and groups. +* Query projects, sites, and more. + +This section describes how to: + +* TOC +{:toc} + +## Confirm prerequisites + +Before you install TSC, confirm that you have the following dependencies installed: + +* Python. You can use TSC with Python 2.7.9 or later and with Python 3.3 or later. These versions include pip, which is + the recommended means of installing TSC. +* Git. Optional, but recommended to download the samples or install from the source code. + + +## Install TSC + +You can install TSC with pip or from the source code. + +### Install with pip (recommended) + +Run the following command to install the latest stable version of TSC: + +``` +pip install tableauserverclient +``` + +### Install from the development branch + +You can install from the development branch for a preview of upcoming features. Run the following command +to install from the development branch: + +``` +pip install git+https://github.com/tableau/server-client-python.git@development +``` + +Note that the version from the development branch should not be used for production code. The methods and endpoints in the +development version are subject to change at any time before the next stable release. + +## Get the samples + +The TSC samples are included in the `samples` directory of the TSC repository on Github. You can run the following command to clone the +repository: + +``` +git clone git@github.com:tableau/server-client-python.git +``` + +For more information on the samples and how to run the samples, see [Samples]({{ site.baseurl }}/docs/samples). + +## Write your first program + +Run the following code to get a list of all the data sources on your installation of Tableau Server: + +```py +import tableauserverclient as TSC + +tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') +server = TSC.Server('http://SERVER_URL') + +with server.auth.sign_in(tableau_auth): + all_datasources, pagination_item = server.datasources.get() + print("\nThere are {} datasources on site: ".format(pagination_item.total_available)) + print([datasource.name for datasource in all_datasources]) +``` diff --git a/docs/docs/page-through-results.md b/docs/docs/page-through-results.md new file mode 100644 index 000000000..b01a1455b --- /dev/null +++ b/docs/docs/page-through-results.md @@ -0,0 +1,69 @@ +--- +title: Page through Results +layout: docs +--- + +Many of the calls that you make with the TSC library query for resources (like workbooks or data sources) on Tableau +Server. Because the number of resources on Tableau Server can be very large, Tableau Server only returns the first 100 +resources by default. To get all of the resources on Tableau Server, you need to page through the results. + +* TOC +{:toc} + +## The Pager generator + +The simplest way to page through results is to use the `Pager` generator on any endpoint with a `get` function. + +For example, to get all of the workbooks on Tableau Server, run the following code: + +```py +for wb in TSC.Pager(server.workbooks): + print(wb.name) +``` + +The `Pager` generator function returns one resource for each time that it is called. To get all the resources on the +server, you can make multiple calls to the `Pager` function. For example, you can use a `for ... in` loop to call the +`Pager` function until there are no resources remaining. Note that the `Pager` generator only makes calls to the Tableau +Server REST API when it runs out of resources--it does not make a call for each resource. + +**Tip**: For more information on generators, see the [Python wiki](https://wiki.python.org/moin/Generators). + +### Set pagination options + +You can set pagination options in the request options and then pass the request options to the `Pager` function as a +second optional parameter. + +For example, to set the page size to 1000 use the following code: + +```py +request_options = TSC.RequestOptions(pagesize=1000) +all_workbooks = list(TSC.Pager(server.workbooks, request_options)) +``` + +You can also set the page number where you want to start like so: + +```py +request_options = TSC.RequestOptions(pagenumber=5) +all_workbooks = list(TSC.Pager(server.workbooks, request_options)) +``` + +### Use list comprehensions and generator expressions + +The `Pager` generator can also be used in list comprehensions or generator expressions for compactness and easy +filtering. Generator expressions will use less memory than list comprehensions. The following example shows how to use +the `Pager` generator with list comprehensions and generator expressions: + +```py +# List comprehension +[wb for wb in TSC.Pager(server.workbooks) if wb.name.startswith('a')] + +# Generator expression +(wb for wb in TSC.Pager(server.workbooks) if wb.name.startswith('a')) +``` + +If you want to load all the resources returned by the `Pager` generator in memory (rather than one at a time), then you +can insert the elements into a list: + +```py +all_workbooks = list(TSC.Pager(server.workbooks)) +``` diff --git a/docs/docs/populate-connections-views.md b/docs/docs/populate-connections-views.md new file mode 100644 index 000000000..63e3fe8f4 --- /dev/null +++ b/docs/docs/populate-connections-views.md @@ -0,0 +1,45 @@ +--- +title: Populate Connections and Views +layout: docs +--- + +When you get a workbook with the TSC library, the response from Tableau Server does not include information about the +views or connections that make up the workbook. Similarly, when you get a data source, the response does not include +information about the connections that make up the data source. This is a result of the design of the Tableau Server +REST API, which optimizes the size of responses by only returning what you ask for explicitly. + +As a result, if you want to get views and connections, you need to run the `populate_views` and `populate_connections` +functions. + +* TOC +{:toc} + +## Populate views for workbooks + +```py +workbook = server.workbooks.get_by_id('a1b2c3d4') +print(workbook.id) + +server.workbooks.populate_views(workbook) +print([view.name for view in workbook.views]) +``` + +## Populate connections for workbooks + +```py +workbook = server.workbooks.get_by_id('a1b2c3d4') +print(workbook.id) + +server.workbooks.populate_connections(workbook) +print([connection.datasource_name for connection in workbook.connections]) +``` + +## Populate connections for data sources + +```py +datasource = server.datasources.get_by_id('a1b2c3d4') +print(datasource.name) + +server.datasources.populate_connections(datasource) +print([connection.datasource_name for connection in datasource.connections]) +``` diff --git a/docs/docs/samples.md b/docs/docs/samples.md new file mode 100644 index 000000000..3ea908dd6 --- /dev/null +++ b/docs/docs/samples.md @@ -0,0 +1,55 @@ +--- +title: Samples +layout: docs +--- + +The TSC samples are included in the `samples` directory of the TSC repository [on Github](https://github.com/tableau/server-client-python). + +* TOC +{:toc} + +## Run the samples + +Each of the samples requires the following arguments: + +* `--server`. The URL for the Tableau Server that you want to connect to. +* `--username`. The user name of the Tableau Server account that you want to use. When you run the samples, you are + prompted for a password for the user account that you enter. + +Additionally, some of the samples require that you enter other arguments when you run them. For more information about +the arguments required by a particular sample, run the sample with the `-h` flag to see the help output. + +For example, if you run the following command: + +``` +python samples/publish_workbook.py -h +``` + +You might see that you need to enter a server address, a user name, and a file path for the workbook that you want to +publish. + +## Samples list + +The following list describes the samples available in the repository: + +* `create_group.py`. Create a user group. + +* `create_schedules.py`. Create schedules for extract refreshes and subscriptions. + +* `explore_datasource.py`. Queries datasources, selects a datasource, populates connections for the datasource, then updates the datasource. + +* `explore_workbook.py`. Queries workbooks, selects a workbook, populates the connections and views for a workbook, then updates the workbook. + +* `move_workbook_projects.py`. Updates the properties of a workbook to move the workbook from one project to another. + +* `move_workbook_sites.py`. Downloads a workbook, stores it in-memory, and uploads it to another site. + +* `pagination_sample.py`. Use the Pager generator to iterate over all the items on the server. + +* `publish_workbook.py`. Publishes a Tableau workbook. + +* `set_http_options.py`. Sets HTTP options for the server and specifically for downloading workbooks. + +**Note**: For all of the samples, ensure that your Tableau Server user account has permission to access the resources +requested by the samples. + diff --git a/docs/docs/sign-in-out.md b/docs/docs/sign-in-out.md new file mode 100644 index 000000000..f619aaeab --- /dev/null +++ b/docs/docs/sign-in-out.md @@ -0,0 +1,39 @@ +--- +title: Sign In and Out +layout: docs +--- + +To sign in and out of Tableau Server, call the `Auth.sign_in` and `Auth.sign_out` functions like so: + +```py +import tableauserverclient as TSC + +tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') +server = TSC.Server('http://SERVER_URL') + +server.auth.sign_in(tableau_auth) + +# Do awesome things here! + +server.auth.sign_out() +``` + +
+ Note: When you sign in, the TSC library manages the authenticated session for you, however it is still + limited by the maximum session length (of four hours) on Tableau Server. +
+ + +Alternatively, for short programs, consider using a `with` block: + +```py +import tableauserverclient as TSC + +tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') +server = TSC.Server('http://SERVER_URL') + +with server.auth.sign_in(tableau_auth): + # Do awesome things here! +``` + +The TSC library signs you out of Tableau Server when you exit out of the `with` block. diff --git a/docs/docs/versions.md b/docs/docs/versions.md new file mode 100644 index 000000000..25f4752a1 --- /dev/null +++ b/docs/docs/versions.md @@ -0,0 +1,45 @@ +--- +title: Versions +layout: docs +--- + +Because the TSC library is a client for the Tableau Server REST API, you need to confirm that the version of the TSC +library that you use is compatible with the version of the REST API used by your installation of Tableau Server. + +* TOC +{:toc} + +## Display the REST API version + +To display the default version of the REST API used by TSC, run the following code: + +```py +import tableauserverclient as TSC + +server = TSC.Server('http://SERVER_URL') + +print(server.version) +``` + +For example, the code might display version `2.3`. + +## Use another version of the REST API + +To use another version of the REST API, set the version like so: + +```py +import tableauserverclient as TSC + +server = TSC.Server('http://SERVER_URL') + +server.version = 2.4 +``` + +## Supported versions + +The current version of TSC only supports the following REST API and Tableau Server versions: + +|REST API version|Tableau Server version| +|---|---| +|2.3|10.0| +|2.4|10.1| diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 000000000..2ae71911c --- /dev/null +++ b/docs/index.md @@ -0,0 +1,12 @@ +--- +layout: home +--- + +
+

Tableau Server Client (Python)

+

The Tableau Server Client is a Python library for the Tableau Server REST API.

+
+ Get Started   + Download +
+ From c86992b0f2f0252a4df0301b0eab14196cc3215f Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Wed, 16 Nov 2016 14:54:29 -0800 Subject: [PATCH 04/37] Initial implementation to address #102 and provide datetime objects --- tableauserverclient/datetime_helpers.py | 37 +++++++++++++++++++ tableauserverclient/models/datasource_item.py | 7 +++- .../models/property_decorators.py | 29 +++++++++++++++ tableauserverclient/models/schedule_item.py | 12 +++++- tableauserverclient/models/workbook_item.py | 12 +++++- tableauserverclient/server/request_factory.py | 1 + test/test_datasource_model.py | 32 ++++++++++++++++ test/test_requests.py | 12 +++--- 8 files changed, 133 insertions(+), 9 deletions(-) create mode 100644 tableauserverclient/datetime_helpers.py diff --git a/tableauserverclient/datetime_helpers.py b/tableauserverclient/datetime_helpers.py new file mode 100644 index 000000000..369c1ea86 --- /dev/null +++ b/tableauserverclient/datetime_helpers.py @@ -0,0 +1,37 @@ +import datetime + +try: + from pytz import utc +except ImportError: + # If pytz is not installed, let's polyfill a UTC timezone so it all just works + # 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) + + + # A UTC class. + + class UTC(datetime.tzinfo): + """UTC""" + + def utcoffset(self, dt): + return ZERO + + def tzname(self, dt): + return "UTC" + + def dst(self, dt): + return ZERO + + + utc = UTC() + +TABLEAU_DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" + + +def parse_datetime(date): + return datetime.datetime.strptime(date, TABLEAU_DATE_FORMAT).replace(tzinfo=utc) + + +def format_datetime(date): + return date.astimezone(tz=utc).strftime(TABLEAU_DATE_FORMAT) \ No newline at end of file diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 3ae4c5743..dfd363b29 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as ET from .exceptions import UnpopulatedPropertyError -from .property_decorators import property_not_nullable +from .property_decorators import property_not_nullable, property_is_datetime from .tag_item import TagItem from .. import NAMESPACE @@ -34,6 +34,11 @@ def content_url(self): def created_at(self): return self._created_at + @created_at.setter + @property_is_datetime + def created_at(self, value): + self._created_at = value + @property def id(self): return self._id diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index de8fe8d8c..77612b172 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -1,5 +1,12 @@ +import datetime import re from functools import wraps +from ..datetime_helpers import parse_datetime +try: + basestring +except NameError: + # In case we are in python 3 the string check is different + basestring = str def property_is_enum(enum_type): @@ -99,3 +106,25 @@ def validate_regex_decorator(self, value): return func(self, value) return validate_regex_decorator return wrapper + + +def property_is_datetime(func): + """ Takes the following datetime format and turns it into a datetime object: + + 2016-08-18T18:25:36Z + + Because we return everything with Z as the timezone, we assume everything is in UTC and create + a timezone aware datetime. + """ + + @wraps(func) + def wrapper(self, value): + if isinstance(value, datetime.datetime): + return func(self, value) + if not isinstance(value, basestring): + raise ValueError("Cannot convert {} into a datetime, cannot update {}".format(value.__class__.__name__, + func.__name__)) + + dt = parse_datetime(value) + return func(self, dt) + return wrapper diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index b0f7d1edb..0819e0205 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -2,7 +2,7 @@ from datetime import datetime from .interval_item import IntervalItem, HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval -from .property_decorators import property_is_enum, property_not_nullable, property_is_int +from .property_decorators import property_is_enum, property_not_nullable, property_is_int, property_is_datetime from .. import NAMESPACE @@ -36,6 +36,11 @@ def __init__(self, name, priority, schedule_type, execution_order, interval_item def created_at(self): return self._created_at + @created_at.setter + @property_is_datetime + def created_at(self, value): + self._created_at = value + @property def end_schedule_at(self): return self._end_schedule_at @@ -98,6 +103,11 @@ def state(self, value): def updated_at(self): return self._updated_at + @updated_at.setter + @property_is_datetime + def updated_at(self, value): + self._updated_at = value + def _parse_common_tags(self, schedule_xml): if not isinstance(schedule_xml, ET.Element): schedule_xml = ET.fromstring(schedule_xml).find('.//t:schedule', namespaces=NAMESPACE) diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 9ccde5606..4231da2f3 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as ET from .exceptions import UnpopulatedPropertyError -from .property_decorators import property_not_nullable, property_is_boolean +from .property_decorators import property_not_nullable, property_is_boolean, property_is_datetime from .tag_item import TagItem from .view_item import ViewItem from .. import NAMESPACE @@ -40,6 +40,11 @@ def content_url(self): def created_at(self): return self._created_at + @created_at.setter + @property_is_datetime + def created_at(self, value): + self._created_at = value + @property def id(self): return self._id @@ -81,6 +86,11 @@ def size(self): def updated_at(self): return self._updated_at + @updated_at.setter + @property_is_datetime + def updated_at(self, value): + self._updated_at = value + @property def views(self): if self._views is None: diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 9a9bf53e1..d2c6976e1 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1,3 +1,4 @@ +from ..datetime_helpers import format_datetime import xml.etree.ElementTree as ET from requests.packages.urllib3.fields import RequestField diff --git a/test/test_datasource_model.py b/test/test_datasource_model.py index b43cc3f3d..1d7fa9b92 100644 --- a/test/test_datasource_model.py +++ b/test/test_datasource_model.py @@ -1,3 +1,4 @@ +import datetime import unittest import tableauserverclient as TSC @@ -8,3 +9,34 @@ def test_invalid_project_id(self): datasource = TSC.DatasourceItem("10") with self.assertRaises(ValueError): datasource.project_id = None + + def test_datetime_conversion(self): + datasource = TSC.DatasourceItem("10") + datasource.created_at = "2016-08-18T19:25:36Z" + actual = datasource.created_at + self.assertIsInstance(actual, datetime.datetime) + self.assertEquals(actual.year, 2016) + self.assertEquals(actual.month, 8) + self.assertEquals(actual.day, 18) + self.assertEquals(actual.hour, 19) + self.assertEquals(actual.minute, 25) + self.assertEquals(actual.second, 36) + + def test_datetime_conversion_allows_datetime_passthrough(self): + datasource = TSC.DatasourceItem("10") + now = datetime.datetime.utcnow() + datasource.created_at = now + self.assertEquals(datasource.created_at, now) + + def test_datetime_conversion_is_timezone_aware(self): + datasource = TSC.DatasourceItem("10") + datasource.created_at = "2016-08-18T19:25:36Z" + actual = datasource.created_at + self.assertEquals(actual.utcoffset().seconds, 0) + + def test_datetime_conversion_rejects_things_that_cannot_be_converted(self): + datasource = TSC.DatasourceItem("10") + with self.assertRaises(ValueError): + datasource.created_at = object() + with self.assertRaises(ValueError): + datasource.created_at = "This is so not a datetime" diff --git a/test/test_requests.py b/test/test_requests.py index 3e8011a0a..686a4bbb4 100644 --- a/test/test_requests.py +++ b/test/test_requests.py @@ -28,9 +28,9 @@ def test_make_get_request(self): auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', content_type='text/xml') - self.assertEquals(resp.request.query, 'pagenumber=13&pagesize=13') - self.assertEquals(resp.request.headers['x-tableau-auth'], 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM') - self.assertEquals(resp.request.headers['content-type'], 'text/xml') + self.assertEqual(resp.request.query, 'pagenumber=13&pagesize=13') + self.assertEqual(resp.request.headers['x-tableau-auth'], 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM') + self.assertEqual(resp.request.headers['content-type'], 'text/xml') def test_make_post_request(self): with requests_mock.mock() as m: @@ -42,6 +42,6 @@ def test_make_post_request(self): request_object=None, auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', content_type='multipart/mixed') - self.assertEquals(resp.request.headers['x-tableau-auth'], 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM') - self.assertEquals(resp.request.headers['content-type'], 'multipart/mixed') - self.assertEquals(resp.request.body, b'1337') + 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') From b2e9e08f0306a63de5776efb96a3f89ec7c9f4cb Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Wed, 16 Nov 2016 14:59:29 -0800 Subject: [PATCH 05/37] Fix pep8 failures --- samples/initialize_server.py | 6 ++++-- samples/pagination_sample.py | 1 + tableauserverclient/datetime_helpers.py | 4 +--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/samples/initialize_server.py b/samples/initialize_server.py index 2136dc588..e37317c0e 100644 --- a/samples/initialize_server.py +++ b/samples/initialize_server.py @@ -10,6 +10,7 @@ import logging import glob + def main(): parser = argparse.ArgumentParser(description='Initialize a server with content.') parser.add_argument('--server', '-s', required=True, help='server address') @@ -47,12 +48,12 @@ 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) - new_site = TSC.SiteItem(name=args.site, content_url=args.site.replace(" ", ""), admin_mode=TSC.SiteItem.AdminMode.ContentAndUsers) + new_site = TSC.SiteItem(name=args.site, content_url=args.site.replace(" ", ""), + admin_mode=TSC.SiteItem.AdminMode.ContentAndUsers) server.sites.create(new_site) else: print("Site {0} exists. Moving on...").format(args.site) - ################################################################################ # Step 3: Sign-in to our target site ################################################################################ @@ -82,6 +83,7 @@ def main(): publish_datasources_to_site(server_upload, project, args.datasources_folder) publish_workbooks_to_site(server_upload, project, args.workbooks_folder) + def publish_datasources_to_site(server_object, project, folder): path = folder + '/*.tds*' diff --git a/samples/pagination_sample.py b/samples/pagination_sample.py index 882fc85ad..25effd7b2 100644 --- a/samples/pagination_sample.py +++ b/samples/pagination_sample.py @@ -66,5 +66,6 @@ def main(): # >>> request_options = TSC.RequestOptions(pagesize=1000) # >>> all_workbooks = list(TSC.Pager(server.workbooks, request_options)) + if __name__ == '__main__': main() diff --git a/tableauserverclient/datetime_helpers.py b/tableauserverclient/datetime_helpers.py index 369c1ea86..f8dbf1edd 100644 --- a/tableauserverclient/datetime_helpers.py +++ b/tableauserverclient/datetime_helpers.py @@ -8,7 +8,6 @@ ZERO = datetime.timedelta(0) HOUR = datetime.timedelta(hours=1) - # A UTC class. class UTC(datetime.tzinfo): @@ -23,7 +22,6 @@ def tzname(self, dt): def dst(self, dt): return ZERO - utc = UTC() TABLEAU_DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" @@ -34,4 +32,4 @@ def parse_datetime(date): def format_datetime(date): - return date.astimezone(tz=utc).strftime(TABLEAU_DATE_FORMAT) \ No newline at end of file + return date.astimezone(tz=utc).strftime(TABLEAU_DATE_FORMAT) From 68fa44275434df62d7f116c3eae7f918cb58ea2a Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Thu, 17 Nov 2016 08:43:08 -0800 Subject: [PATCH 06/37] Remove setters and move to doing the conversion during parsing --- .travis.yml | 2 +- tableauserverclient/datetime_helpers.py | 3 ++ tableauserverclient/models/datasource_item.py | 12 ++---- tableauserverclient/models/schedule_item.py | 21 +++------ tableauserverclient/models/user_item.py | 3 +- tableauserverclient/models/workbook_item.py | 17 ++------ test/test_datasource.py | 17 ++++---- test/test_datasource_model.py | 31 ------------- test/test_group.py | 3 +- test/test_schedule.py | 43 ++++++++++--------- test/test_user.py | 9 ++-- test/test_workbook.py | 17 ++++---- 12 files changed, 67 insertions(+), 111 deletions(-) diff --git a/.travis.yml b/.travis.yml index b0d0b8b7b..255151e56 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,4 +14,4 @@ script: # Tests - python setup.py test # pep8 - disabled for now until we can scrub the files to make sure we pass before turning it on - - pycodestyle . + - pycodestyle tableauserverclient test diff --git a/tableauserverclient/datetime_helpers.py b/tableauserverclient/datetime_helpers.py index f8dbf1edd..32e762385 100644 --- a/tableauserverclient/datetime_helpers.py +++ b/tableauserverclient/datetime_helpers.py @@ -28,6 +28,9 @@ def dst(self, dt): def parse_datetime(date): + if date is None: + return None + return datetime.datetime.strptime(date, TABLEAU_DATE_FORMAT).replace(tzinfo=utc) diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index dfd363b29..2ae469674 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -1,8 +1,9 @@ import xml.etree.ElementTree as ET from .exceptions import UnpopulatedPropertyError -from .property_decorators import property_not_nullable, property_is_datetime +from .property_decorators import property_not_nullable from .tag_item import TagItem from .. import NAMESPACE +from ..datetime_helpers import parse_datetime class DatasourceItem(object): @@ -34,11 +35,6 @@ def content_url(self): def created_at(self): return self._created_at - @created_at.setter - @property_is_datetime - def created_at(self, value): - self._created_at = value - @property def id(self): return self._id @@ -123,8 +119,8 @@ def _parse_element(datasource_xml): name = datasource_xml.get('name', None) datasource_type = datasource_xml.get('type', None) content_url = datasource_xml.get('contentUrl', None) - created_at = datasource_xml.get('createdAt', None) - updated_at = datasource_xml.get('updatedAt', None) + created_at = parse_datetime(datasource_xml.get('createdAt', None)) + updated_at = parse_datetime(datasource_xml.get('updatedAt', None)) tags = None tags_elem = datasource_xml.find('.//t:tags', namespaces=NAMESPACE) diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index 0819e0205..84b070044 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -2,8 +2,9 @@ from datetime import datetime from .interval_item import IntervalItem, HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval -from .property_decorators import property_is_enum, property_not_nullable, property_is_int, property_is_datetime +from .property_decorators import property_is_enum, property_not_nullable, property_is_int from .. import NAMESPACE +from ..datetime_helpers import parse_datetime class ScheduleItem(object): @@ -36,11 +37,6 @@ def __init__(self, name, priority, schedule_type, execution_order, interval_item def created_at(self): return self._created_at - @created_at.setter - @property_is_datetime - def created_at(self, value): - self._created_at = value - @property def end_schedule_at(self): return self._end_schedule_at @@ -103,11 +99,6 @@ def state(self, value): def updated_at(self): return self._updated_at - @updated_at.setter - @property_is_datetime - def updated_at(self, value): - self._updated_at = value - def _parse_common_tags(self, schedule_xml): if not isinstance(schedule_xml, ET.Element): schedule_xml = ET.fromstring(schedule_xml).find('.//t:schedule', namespaces=NAMESPACE) @@ -218,12 +209,12 @@ def _parse_element(schedule_xml): id = schedule_xml.get('id', None) name = schedule_xml.get('name', None) state = schedule_xml.get('state', None) - created_at = schedule_xml.get('createdAt', None) - updated_at = schedule_xml.get('updatedAt', None) + created_at = parse_datetime(schedule_xml.get('createdAt', None)) + updated_at = parse_datetime(schedule_xml.get('updatedAt', None)) schedule_type = schedule_xml.get('type', None) frequency = schedule_xml.get('frequency', None) - next_run_at = schedule_xml.get('nextRunAt', None) - end_schedule_at = schedule_xml.get('endScheduleAt', None) + next_run_at = parse_datetime(schedule_xml.get('nextRunAt', None)) + end_schedule_at = parse_datetime(schedule_xml.get('endScheduleAt', None)) execution_order = schedule_xml.get('executionOrder', None) priority = schedule_xml.get('priority', None) diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 49a048f69..1e4f54af9 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -2,6 +2,7 @@ from .exceptions import UnpopulatedPropertyError from .property_decorators import property_is_enum, property_not_empty, property_not_nullable from .. import NAMESPACE +from ..datetime_helpers import parse_datetime class UserItem(object): @@ -135,7 +136,7 @@ def _parse_element(user_xml): id = user_xml.get('id', None) name = user_xml.get('name', None) site_role = user_xml.get('siteRole', None) - last_login = user_xml.get('lastLogin', None) + last_login = parse_datetime(user_xml.get('lastLogin', None)) external_auth_user_id = user_xml.get('externalAuthUserId', None) fullname = user_xml.get('fullName', None) email = user_xml.get('email', None) diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 4231da2f3..26a3a00c3 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -1,9 +1,10 @@ import xml.etree.ElementTree as ET from .exceptions import UnpopulatedPropertyError -from .property_decorators import property_not_nullable, property_is_boolean, property_is_datetime +from .property_decorators import property_not_nullable, property_is_boolean from .tag_item import TagItem from .view_item import ViewItem from .. import NAMESPACE +from ..datetime_helpers import parse_datetime import copy @@ -40,11 +41,6 @@ def content_url(self): def created_at(self): return self._created_at - @created_at.setter - @property_is_datetime - def created_at(self, value): - self._created_at = value - @property def id(self): return self._id @@ -86,11 +82,6 @@ def size(self): def updated_at(self): return self._updated_at - @updated_at.setter - @property_is_datetime - def updated_at(self, value): - self._updated_at = value - @property def views(self): if self._views is None: @@ -173,8 +164,8 @@ def _parse_element(workbook_xml): id = workbook_xml.get('id', None) name = workbook_xml.get('name', None) content_url = workbook_xml.get('contentUrl', None) - created_at = workbook_xml.get('createdAt', None) - updated_at = workbook_xml.get('updatedAt', None) + created_at = parse_datetime(workbook_xml.get('createdAt', None)) + updated_at = parse_datetime(workbook_xml.get('updatedAt', None)) size = workbook_xml.get('size', None) if size: diff --git a/test/test_datasource.py b/test/test_datasource.py index d01f3cb0f..9a1e07a24 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -2,6 +2,7 @@ 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') @@ -33,8 +34,8 @@ def test_get(self): self.assertEqual('e76a1461-3b1d-4588-bf1b-17551a879ad9', all_datasources[0].id) self.assertEqual('dataengine', all_datasources[0].datasource_type) self.assertEqual('SampleDS', all_datasources[0].content_url) - self.assertEqual('2016-08-11T21:22:40Z', all_datasources[0].created_at) - self.assertEqual('2016-08-11T21:34:17Z', all_datasources[0].updated_at) + 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) @@ -43,8 +44,8 @@ def test_get(self): self.assertEqual('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', all_datasources[1].id) self.assertEqual('dataengine', all_datasources[1].datasource_type) self.assertEqual('Sampledatasource', all_datasources[1].content_url) - self.assertEqual('2016-08-04T21:31:55Z', all_datasources[1].created_at) - self.assertEqual('2016-08-04T21:31:55Z', all_datasources[1].updated_at) + 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) @@ -75,8 +76,8 @@ def test_get_by_id(self): self.assertEqual('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', single_datasource.id) self.assertEqual('dataengine', single_datasource.datasource_type) self.assertEqual('Sampledatasource', single_datasource.content_url) - self.assertEqual('2016-08-04T21:31:55Z', single_datasource.created_at) - self.assertEqual('2016-08-04T21:31:55Z', single_datasource.updated_at) + 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) @@ -125,8 +126,8 @@ def test_publish(self): 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', new_datasource.created_at) - self.assertEqual('2016-08-17T23:37:08Z', new_datasource.updated_at) + 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) diff --git a/test/test_datasource_model.py b/test/test_datasource_model.py index 1d7fa9b92..600587801 100644 --- a/test/test_datasource_model.py +++ b/test/test_datasource_model.py @@ -9,34 +9,3 @@ def test_invalid_project_id(self): datasource = TSC.DatasourceItem("10") with self.assertRaises(ValueError): datasource.project_id = None - - def test_datetime_conversion(self): - datasource = TSC.DatasourceItem("10") - datasource.created_at = "2016-08-18T19:25:36Z" - actual = datasource.created_at - self.assertIsInstance(actual, datetime.datetime) - self.assertEquals(actual.year, 2016) - self.assertEquals(actual.month, 8) - self.assertEquals(actual.day, 18) - self.assertEquals(actual.hour, 19) - self.assertEquals(actual.minute, 25) - self.assertEquals(actual.second, 36) - - def test_datetime_conversion_allows_datetime_passthrough(self): - datasource = TSC.DatasourceItem("10") - now = datetime.datetime.utcnow() - datasource.created_at = now - self.assertEquals(datasource.created_at, now) - - def test_datetime_conversion_is_timezone_aware(self): - datasource = TSC.DatasourceItem("10") - datasource.created_at = "2016-08-18T19:25:36Z" - actual = datasource.created_at - self.assertEquals(actual.utcoffset().seconds, 0) - - def test_datetime_conversion_rejects_things_that_cannot_be_converted(self): - datasource = TSC.DatasourceItem("10") - with self.assertRaises(ValueError): - datasource.created_at = object() - with self.assertRaises(ValueError): - datasource.created_at = "This is so not a datetime" diff --git a/test/test_group.py b/test/test_group.py index ff928bf17..2f7f22701 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -3,6 +3,7 @@ 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') @@ -61,7 +62,7 @@ def test_populate_users(self): 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', user.last_login) + self.assertEqual('2016-08-16T23:17:06Z', format_datetime(user.last_login)) def test_delete(self): with requests_mock.mock() as m: diff --git a/test/test_schedule.py b/test/test_schedule.py index 710bfe2a2..965e414a8 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -1,8 +1,9 @@ +from datetime import time import unittest import os import requests_mock import tableauserverclient as TSC -from datetime import time +from tableauserverclient.datetime_helpers import format_datetime TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -37,19 +38,19 @@ def test_get(self): self.assertEqual("Weekday early mornings", all_schedules[0].name) self.assertEqual("Active", all_schedules[0].state) self.assertEqual(50, all_schedules[0].priority) - self.assertEqual("2016-07-06T20:19:00Z", all_schedules[0].created_at) - self.assertEqual("2016-09-13T11:00:32Z", all_schedules[0].updated_at) + self.assertEqual("2016-07-06T20:19:00Z", format_datetime(all_schedules[0].created_at)) + self.assertEqual("2016-09-13T11:00:32Z", format_datetime(all_schedules[0].updated_at)) self.assertEqual("Extract", all_schedules[0].schedule_type) - self.assertEqual("2016-09-14T11:00:00Z", all_schedules[0].next_run_at) + self.assertEqual("2016-09-14T11:00:00Z", format_datetime(all_schedules[0].next_run_at)) self.assertEqual("bcb79d07-6e47-472f-8a65-d7f51f40c36c", all_schedules[1].id) self.assertEqual("Saturday night", all_schedules[1].name) self.assertEqual("Active", all_schedules[1].state) self.assertEqual(80, all_schedules[1].priority) - self.assertEqual("2016-07-07T20:19:00Z", all_schedules[1].created_at) - self.assertEqual("2016-09-12T16:39:38Z", all_schedules[1].updated_at) + self.assertEqual("2016-07-07T20:19:00Z", format_datetime(all_schedules[1].created_at)) + self.assertEqual("2016-09-12T16:39:38Z", format_datetime(all_schedules[1].updated_at)) self.assertEqual("Subscription", all_schedules[1].schedule_type) - self.assertEqual("2016-09-18T06:00:00Z", all_schedules[1].next_run_at) + self.assertEqual("2016-09-18T06:00:00Z", format_datetime(all_schedules[1].next_run_at)) def test_get_empty(self): with open(GET_EMPTY_XML, "rb") as f: @@ -82,10 +83,10 @@ def test_create_hourly(self): self.assertEqual("hourly-schedule-1", new_schedule.name) self.assertEqual("Active", new_schedule.state) self.assertEqual(50, new_schedule.priority) - self.assertEqual("2016-09-15T20:47:33Z", new_schedule.created_at) - self.assertEqual("2016-09-15T20:47:33Z", new_schedule.updated_at) + self.assertEqual("2016-09-15T20:47:33Z", format_datetime(new_schedule.created_at)) + self.assertEqual("2016-09-15T20:47:33Z", format_datetime(new_schedule.updated_at)) self.assertEqual(TSC.ScheduleItem.Type.Extract, new_schedule.schedule_type) - self.assertEqual("2016-09-16T01:30:00Z", new_schedule.next_run_at) + 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) @@ -105,10 +106,10 @@ def test_create_daily(self): self.assertEqual("daily-schedule-1", new_schedule.name) self.assertEqual("Active", new_schedule.state) self.assertEqual(90, new_schedule.priority) - self.assertEqual("2016-09-15T21:01:09Z", new_schedule.created_at) - self.assertEqual("2016-09-15T21:01:09Z", new_schedule.updated_at) + self.assertEqual("2016-09-15T21:01:09Z", format_datetime(new_schedule.created_at)) + self.assertEqual("2016-09-15T21:01:09Z", format_datetime(new_schedule.updated_at)) self.assertEqual(TSC.ScheduleItem.Type.Subscription, new_schedule.schedule_type) - self.assertEqual("2016-09-16T11:45:00Z", new_schedule.next_run_at) + self.assertEqual("2016-09-16T11:45:00Z", format_datetime(new_schedule.next_run_at)) self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Serial, new_schedule.execution_order) self.assertEqual(time(4, 45), new_schedule.interval_item.start_time) @@ -128,10 +129,10 @@ def test_create_weekly(self): self.assertEqual("weekly-schedule-1", new_schedule.name) self.assertEqual("Active", new_schedule.state) self.assertEqual(80, new_schedule.priority) - self.assertEqual("2016-09-15T21:12:50Z", new_schedule.created_at) - self.assertEqual("2016-09-15T21:12:50Z", new_schedule.updated_at) + self.assertEqual("2016-09-15T21:12:50Z", format_datetime(new_schedule.created_at)) + self.assertEqual("2016-09-15T21:12:50Z", format_datetime(new_schedule.updated_at)) self.assertEqual(TSC.ScheduleItem.Type.Extract, new_schedule.schedule_type) - self.assertEqual("2016-09-16T16:15:00Z", new_schedule.next_run_at) + 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"), @@ -151,10 +152,10 @@ def test_create_monthly(self): self.assertEqual("monthly-schedule-1", new_schedule.name) self.assertEqual("Active", new_schedule.state) self.assertEqual(20, new_schedule.priority) - self.assertEqual("2016-09-15T21:16:56Z", new_schedule.created_at) - self.assertEqual("2016-09-15T21:16:56Z", new_schedule.updated_at) + self.assertEqual("2016-09-15T21:16:56Z", format_datetime(new_schedule.created_at)) + self.assertEqual("2016-09-15T21:16:56Z", format_datetime(new_schedule.updated_at)) self.assertEqual(TSC.ScheduleItem.Type.Extract, new_schedule.schedule_type) - self.assertEqual("2016-10-12T14:00:00Z", new_schedule.next_run_at) + 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) @@ -174,9 +175,9 @@ def test_update(self): self.assertEqual("7bea1766-1543-4052-9753-9d224bc069b5", single_schedule.id) self.assertEqual("weekly-schedule-1", single_schedule.name) self.assertEqual(90, single_schedule.priority) - self.assertEqual("2016-09-15T23:50:02Z", single_schedule.updated_at) + self.assertEqual("2016-09-15T23:50:02Z", format_datetime(single_schedule.updated_at)) self.assertEqual(TSC.ScheduleItem.Type.Extract, single_schedule.schedule_type) - self.assertEqual("2016-09-16T14:00:00Z", single_schedule.next_run_at) + 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"), diff --git a/test/test_user.py b/test/test_user.py index 71ec30207..556cd62a4 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -2,6 +2,7 @@ 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') @@ -38,7 +39,7 @@ def test_get(self): 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', single_user.last_login) + self.assertEqual('2016-08-16T23:17:06Z', format_datetime(single_user.last_login)) 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') @@ -71,7 +72,7 @@ def test_get_by_id(self): 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', single_user.last_login) + 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): @@ -136,8 +137,8 @@ def test_populate_workbooks(self): 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', workbook_list[0].created_at) - self.assertEqual('2016-07-26T20:35:05Z', workbook_list[0].updated_at) + 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) diff --git a/test/test_workbook.py b/test/test_workbook.py index e99d07f81..4ad38b17d 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -2,6 +2,7 @@ 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') @@ -39,8 +40,8 @@ def test_get(self): self.assertEqual('Superstore', all_workbooks[0].content_url) self.assertEqual(False, all_workbooks[0].show_tabs) self.assertEqual(1, all_workbooks[0].size) - self.assertEqual('2016-08-03T20:34:04Z', all_workbooks[0].created_at) - self.assertEqual('2016-08-04T17:56:41Z', all_workbooks[0].updated_at) + self.assertEqual('2016-08-03T20:34:04Z', format_datetime(all_workbooks[0].created_at)) + 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) @@ -50,8 +51,8 @@ def test_get(self): self.assertEqual('SafariSample', all_workbooks[1].content_url) self.assertEqual(False, all_workbooks[1].show_tabs) self.assertEqual(26, all_workbooks[1].size) - self.assertEqual('2016-07-26T20:34:56Z', all_workbooks[1].created_at) - self.assertEqual('2016-07-26T20:35:05Z', all_workbooks[1].updated_at) + self.assertEqual('2016-07-26T20:34:56Z', format_datetime(all_workbooks[1].created_at)) + 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) @@ -83,8 +84,8 @@ def test_get_by_id(self): self.assertEqual('SafariSample', single_workbook.content_url) self.assertEqual(False, single_workbook.show_tabs) self.assertEqual(26, single_workbook.size) - self.assertEqual('2016-07-26T20:34:56Z', single_workbook.created_at) - self.assertEqual('2016-07-26T20:35:05Z', single_workbook.updated_at) + self.assertEqual('2016-07-26T20:34:56Z', format_datetime(single_workbook.created_at)) + 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) @@ -250,8 +251,8 @@ def test_publish(self): 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', new_workbook.created_at) - self.assertEqual('2016-08-18T20:31:34Z', new_workbook.updated_at) + 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) From ff23f08458acaf25602814a978200af5ca9fda34 Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Thu, 17 Nov 2016 10:33:35 -0800 Subject: [PATCH 07/37] removing attempt to import pytz --- tableauserverclient/datetime_helpers.py | 31 +++++++++++-------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/tableauserverclient/datetime_helpers.py b/tableauserverclient/datetime_helpers.py index 32e762385..0714eecf4 100644 --- a/tableauserverclient/datetime_helpers.py +++ b/tableauserverclient/datetime_helpers.py @@ -1,28 +1,25 @@ import datetime -try: - from pytz import utc -except ImportError: - # If pytz is not installed, let's polyfill a UTC timezone so it all just works - # 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) - # A UTC class. +# 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) - class UTC(datetime.tzinfo): - """UTC""" +# A UTC class. - def utcoffset(self, dt): - return ZERO +class UTC(datetime.tzinfo): + """UTC""" - def tzname(self, dt): - return "UTC" + def utcoffset(self, dt): + return ZERO - def dst(self, dt): - return ZERO + def tzname(self, dt): + return "UTC" - utc = UTC() + def dst(self, dt): + return ZERO + +utc = UTC() TABLEAU_DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" From db0dac1a43781157c3af701aaa1bc6f0050451a6 Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Thu, 17 Nov 2016 11:11:04 -0800 Subject: [PATCH 08/37] fixing yet another pep8 failure --- tableauserverclient/datetime_helpers.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tableauserverclient/datetime_helpers.py b/tableauserverclient/datetime_helpers.py index 0714eecf4..af88d5c71 100644 --- a/tableauserverclient/datetime_helpers.py +++ b/tableauserverclient/datetime_helpers.py @@ -7,6 +7,7 @@ # A UTC class. + class UTC(datetime.tzinfo): """UTC""" @@ -19,6 +20,7 @@ def tzname(self, dt): def dst(self, dt): return ZERO + utc = UTC() TABLEAU_DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" From c0c31cc38b39ccc3c81ccb45eb2c6ba571090ecc Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Fri, 2 Dec 2016 09:38:46 -0800 Subject: [PATCH 09/37] Fix Pager by making UserItem return a list like the other models (#109) Fixes #107 --- tableauserverclient/models/user_item.py | 4 ++-- test/test_user.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 1e4f54af9..2df6764d9 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -119,7 +119,7 @@ def _set_values(self, id, name, site_role, last_login, @classmethod def from_response(cls, resp): - all_user_items = set() + all_user_items = [] parsed_response = ET.fromstring(resp) all_user_xml = parsed_response.findall('.//t:user', namespaces=NAMESPACE) for user_xml in all_user_xml: @@ -128,7 +128,7 @@ def from_response(cls, resp): user_item = cls(name, site_role) user_item._set_values(id, name, site_role, last_login, external_auth_user_id, fullname, email, auth_setting, domain_name) - all_user_items.add(user_item) + all_user_items.append(user_item) return all_user_items @staticmethod diff --git a/test/test_user.py b/test/test_user.py index 556cd62a4..fa8344371 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -54,7 +54,7 @@ def test_get_empty(self): all_users, pagination_item = self.server.users.get() self.assertEqual(0, pagination_item.total_available) - self.assertEqual(set(), all_users) + self.assertEqual([], all_users) def test_get_before_signin(self): self.server._auth_token = None From c2230ae09f9cccb9a868a7eae09c21977ec85354 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Fri, 9 Dec 2016 10:52:17 -0800 Subject: [PATCH 10/37] Add deprecation warning to site setter too (#97) --- tableauserverclient/models/tableau_auth.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 7670e2812..3b60741d6 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -18,3 +18,10 @@ def site(self): warnings.warn('TableauAuth.site is deprecated, use TableauAuth.site_id instead.', DeprecationWarning) return self.site_id + + @site.setter + def site(self, value): + import warnings + warnings.warn('TableauAuth.site is deprecated, use TableauAuth.site_id instead.', + DeprecationWarning) + self.site_id = value From a7ca70949de7c926ea8a4babbddce55310bcd58f Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Tue, 13 Dec 2016 16:30:22 -0800 Subject: [PATCH 11/37] Fix large downloads (#105) (#111) Large responses were being read into memory. For most calls that's fine, but download could cause the python process to go out of memory due to holding large workbooks or datasources all in memory before writing to disk. Requests has a feature called `iter_content` which when used in combination with `stream=True` on a request will download only the headers, allow us to determine the filename, and then read through the response body in chunks. I picked a size of 1024 bytes, since that's what most of the internet appears to use and I noticed little perf difference between a 1024 byte chunk size and a 1MB chunk size. This is all enabled by exposing the `parameters` argument to `requests.get` by pluming it through our wrapper functions. All tests pass, and manual testing showed the memory problem went away. --- .../server/endpoint/datasources_endpoint.py | 23 +++++++++++-------- .../server/endpoint/endpoint.py | 9 ++++---- .../server/endpoint/workbooks_endpoint.py | 23 +++++++++++-------- 3 files changed, 31 insertions(+), 24 deletions(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index e8e4e4bf6..af8efcd13 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -6,6 +6,7 @@ import logging import copy import cgi +from contextlib import closing # The maximum size of a file that can be published in a single request is 64MB FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB @@ -64,16 +65,18 @@ def download(self, datasource_id, filepath=None): error = "Datasource ID undefined." raise ValueError(error) url = "{0}/{1}/content".format(self.baseurl, datasource_id) - server_response = self.get_request(url) - _, params = cgi.parse_header(server_response.headers['Content-Disposition']) - filename = os.path.basename(params['filename']) - if filepath is None: - filepath = filename - elif os.path.isdir(filepath): - filepath = os.path.join(filepath, filename) - - with open(filepath, 'wb') as f: - f.write(server_response.content) + with closing(self.get_request(url, parameters={'stream': True})) as server_response: + _, params = cgi.parse_header(server_response.headers['Content-Disposition']) + filename = os.path.basename(params['filename']) + if filepath is None: + filepath = filename + elif os.path.isdir(filepath): + filepath = os.path.join(filepath, filename) + + with open(filepath, 'wb') as f: + for chunk in server_response.iter_content(1024): # 1KB + f.write(chunk) + logger.info('Downloaded datasource to {0} (ID: {1})'.format(filepath, datasource_id)) return os.path.abspath(filepath) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index c90b91004..e29ab3d82 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -21,10 +21,11 @@ def _make_common_headers(auth_token, content_type): return headers - def _make_request(self, method, url, content=None, request_object=None, auth_token=None, content_type=None): + def _make_request(self, method, url, content=None, request_object=None, + auth_token=None, content_type=None, parameters=None): if request_object is not None: url = request_object.apply_query_params(url) - parameters = {} + parameters = parameters or {} parameters.update(self.parent_srv.http_options) parameters['headers'] = Endpoint._make_common_headers(auth_token, content_type) @@ -49,9 +50,9 @@ def _check_status(server_response): def get_unauthenticated_request(self, url, request_object=None): return self._make_request(self.parent_srv.session.get, url, request_object=request_object) - def get_request(self, url, request_object=None): + def get_request(self, url, request_object=None, parameters=None): return self._make_request(self.parent_srv.session.get, url, auth_token=self.parent_srv.auth_token, - request_object=request_object) + request_object=request_object, parameters=parameters) def delete_request(self, url): # We don't return anything for a delete diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 6aabc6029..eb185476e 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -7,6 +7,7 @@ import logging import copy import cgi +from contextlib import closing # The maximum size of a file that can be published in a single request is 64MB FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB @@ -92,16 +93,18 @@ def download(self, workbook_id, filepath=None): error = "Workbook ID undefined." raise ValueError(error) url = "{0}/{1}/content".format(self.baseurl, workbook_id) - server_response = self.get_request(url) - _, params = cgi.parse_header(server_response.headers['Content-Disposition']) - filename = os.path.basename(params['filename']) - if filepath is None: - filepath = filename - elif os.path.isdir(filepath): - filepath = os.path.join(filepath, filename) - - with open(filepath, 'wb') as f: - f.write(server_response.content) + + with closing(self.get_request(url, parameters={"stream": True})) as server_response: + _, params = cgi.parse_header(server_response.headers['Content-Disposition']) + filename = os.path.basename(params['filename']) + if filepath is None: + filepath = filename + elif os.path.isdir(filepath): + filepath = os.path.join(filepath, filename) + + with open(filepath, 'wb') as f: + for chunk in server_response.iter_content(1024): # 1KB + f.write(chunk) logger.info('Downloaded workbook to {0} (ID: {1})'.format(filepath, workbook_id)) return os.path.abspath(filepath) From f551c0b6f65f0733938281781a3af5610eb126ca Mon Sep 17 00:00:00 2001 From: Hugo Stijns Date: Fri, 23 Dec 2016 20:20:43 +0100 Subject: [PATCH 12/37] Enhancement #117: Add support for the oAuth flag * Add support for the oAuth flag when publishing workbooks and data sources --- tableauserverclient/models/connection_credentials.py | 12 +++++++++++- tableauserverclient/server/request_factory.py | 6 ++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/models/connection_credentials.py b/tableauserverclient/models/connection_credentials.py index d823b0b7f..8c3a77925 100644 --- a/tableauserverclient/models/connection_credentials.py +++ b/tableauserverclient/models/connection_credentials.py @@ -9,10 +9,11 @@ class ConnectionCredentials(object): """ - def __init__(self, name, password, embed=True): + def __init__(self, name, password, embed=True, oauth=False): self.name = name self.password = password self.embed = embed + self.oauth = oauth @property def embed(self): @@ -22,3 +23,12 @@ def embed(self): @property_is_boolean def embed(self, value): self._embed = value + + @property + def oauth(self): + return self._oauth + + @oauth.setter + @property_is_boolean + def oauth(self, value): + self._oauth = value diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index d2c6976e1..db82b52aa 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -42,6 +42,9 @@ def _generate_xml(self, datasource_item, connection_credentials=None): 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' + + if connection_credentials.oauth: + credentials_element.attrib['oAuth'] = 'true' return ET.tostring(xml_request) def update_req(self, datasource_item): @@ -279,6 +282,9 @@ def _generate_xml(self, workbook_item, connection_credentials=None): 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' + + if connection_credentials.oauth: + credentials_element.attrib['oAuth'] = 'true' return ET.tostring(xml_request) def update_req(self, workbook_item): From 49922e1ff2bf17dbb613dc512fd45c8d951ffbaf Mon Sep 17 00:00:00 2001 From: Ben Lower Date: Wed, 28 Dec 2016 15:12:11 -0800 Subject: [PATCH 13/37] New sample: Migrate with Datasources This sample shows how to migrate workbooks from one site to another and change their datasources using the Tableau Document API --- samples/migrate_with_datasources.py | 147 ++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 samples/migrate_with_datasources.py diff --git a/samples/migrate_with_datasources.py b/samples/migrate_with_datasources.py new file mode 100644 index 000000000..65988ee38 --- /dev/null +++ b/samples/migrate_with_datasources.py @@ -0,0 +1,147 @@ +#### +# This script will move workbooks from one site to another. It will find workbooks with a given tag, download them, +# and then publish them to the destination site. Before moving the workbooks, we (optionally) modify them to point to +# production datasources based on information contained in a CSV file. +# +# If a CSV file is used, it is assumed to have two columns: source_ds and dest_ds. +# +# To run the script, you must have installed Python 2.7.9 or later. +#### + + +import argparse +import csv +import getpass +import logging +import shutil +import tableaudocumentapi as TDA +import tableauserverclient as TSC +import tempfile + + +def main(): + parser = argparse.ArgumentParser(description='Move workbooks with the given tag from one project to another.') + parser.add_argument('--server', '-s', required=True, help='server address') + parser.add_argument('--username', '-u', required=True, help='username to sign into server') + parser.add_argument('--source-site', '-ss', required=True, help='source site to get workbooks from') + parser.add_argument('--dest-site', '-ds', required=True, help='destination site to copy workbooks to') + parser.add_argument('--tag', '-t', required=True, help='tag to search for') + parser.add_argument('--csv', '-c', required=False, help='CSV file containing database info') + parser.add_argument('--delete-source', '-d', required=False, help='use true to delete source wbs after migration') + 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() + db_info = None + password = getpass.getpass("Password: ") + + # 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 twice because the destination site has a + # different site id and requires second server object + auth_source = TSC.TableauAuth(args.username, password, args.source_site) + auth_dest = TSC.TableauAuth(args.username, password, args.dest_site) + + server = TSC.Server(args.server) + dest_server = TSC.Server(args.server) + + with server.auth.sign_in(auth_source): + # Step 2: Verify our source and destination sites exist + found_source_site = False + found_dest_site = False + + found_source_site, found_dest_site = verify_sites(server, args.source_site, args.dest_site) + + # Step 3: get all workbooks with the tag (e.g. 'ready-for-prod') using a filter + req_option = TSC.RequestOptions() + req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.Equals, args.tag)) + all_workbooks, pagination_item = server.workbooks.get(req_option) + + # Step 4: Download workbooks to a temp dir and loop thru them + if len(all_workbooks) > 0: + tmpdir = tempfile.mkdtemp() + + try: + # We got a CSV so let's make a dictionary + if args.csv: + db_info = dict_from_csv(args.csv) + + # Signing into another site requires another server object b/c of the different auth token and site ID + with dest_server.auth.sign_in(auth_dest): + for wb in all_workbooks: + wb_path = server.workbooks.download(wb.id, tmpdir) + + # Step 5: If we have a CSV of data sources then update each workbook db connection per our CSV + if db_info: + source_wb = TDA.Workbook(wb_path) + + # if we have more than one datasource we need to loop + for ds in source_wb.datasources: + for c in ds.connections: + if c.dbname in db_info.keys(): + c.dbname = db_info[c.dbname] + ds.caption = c.dbname + + source_wb.save_as(wb_path) + + # Step 6: Find destination site's default project + dest_sites, _ = dest_server.projects.get() + target_project = next((project for project in dest_sites if project.is_default()), None) + + # Step 7: If default project is found, form a new workbook item and publish + if target_project is not None: + new_workbook = TSC.WorkbookItem(name=wb.name, project_id=target_project.id) + new_workbook = dest_server.workbooks.publish( + new_workbook, wb_path, mode=TSC.Server.PublishMode.Overwrite) + + print("Successfully moved {0} ({1})".format( + new_workbook.name, new_workbook.id)) + else: + error = "The default project could not be found." + raise LookupError(error) + + # Step 8: (if requested) Delete workbook from source site and delete temp directory + if args.delete_source: + server.workbooks.delete(wb.id) + finally: + shutil.rmtree(tmpdir) + + # No workbooks found + else: + print('No workbooks with tag {} found.'.format(args.tag)) + + +# Takes a Tableau Server URL and two site names. Returns true, true if the sites exist on the server + +def verify_sites(server, site1, site2): + found_site1 = False + found_site2 = False + + # Use the Pager to get all the sites + for site in TSC.Pager(server.sites): + if site1.lower() == site.content_url.lower(): + found_site1 = True + if site2.lower() == site.content_url.lower(): + found_site2 = True + + if not found_site1: + error = "Site named {} not found.".format(site1) + raise LookupError(error) + + if not found_site2: + error = "Site named {} not found.".format(site2) + raise LookupError(error) + + return found_site1, found_site2 + + +# Returns a dictionary from a CSV file + +def dict_from_csv(csv_file): + with open(csv_file) as csvfile: + return {value['source_ds']: value['dest_ds'] for value in csv.DictReader(csvfile)} + + +if __name__ == "__main__": + main() From 9a3c8c690a3e78a722e1cf7b085727641a0344b6 Mon Sep 17 00:00:00 2001 From: Ben Lower Date: Wed, 28 Dec 2016 15:15:20 -0800 Subject: [PATCH 14/37] Revert "New sample: Migrate with Datasources" This reverts commit 49922e1ff2bf17dbb613dc512fd45c8d951ffbaf. --- samples/migrate_with_datasources.py | 147 ---------------------------- 1 file changed, 147 deletions(-) delete mode 100644 samples/migrate_with_datasources.py diff --git a/samples/migrate_with_datasources.py b/samples/migrate_with_datasources.py deleted file mode 100644 index 65988ee38..000000000 --- a/samples/migrate_with_datasources.py +++ /dev/null @@ -1,147 +0,0 @@ -#### -# This script will move workbooks from one site to another. It will find workbooks with a given tag, download them, -# and then publish them to the destination site. Before moving the workbooks, we (optionally) modify them to point to -# production datasources based on information contained in a CSV file. -# -# If a CSV file is used, it is assumed to have two columns: source_ds and dest_ds. -# -# To run the script, you must have installed Python 2.7.9 or later. -#### - - -import argparse -import csv -import getpass -import logging -import shutil -import tableaudocumentapi as TDA -import tableauserverclient as TSC -import tempfile - - -def main(): - parser = argparse.ArgumentParser(description='Move workbooks with the given tag from one project to another.') - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--username', '-u', required=True, help='username to sign into server') - parser.add_argument('--source-site', '-ss', required=True, help='source site to get workbooks from') - parser.add_argument('--dest-site', '-ds', required=True, help='destination site to copy workbooks to') - parser.add_argument('--tag', '-t', required=True, help='tag to search for') - parser.add_argument('--csv', '-c', required=False, help='CSV file containing database info') - parser.add_argument('--delete-source', '-d', required=False, help='use true to delete source wbs after migration') - 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() - db_info = None - password = getpass.getpass("Password: ") - - # 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 twice because the destination site has a - # different site id and requires second server object - auth_source = TSC.TableauAuth(args.username, password, args.source_site) - auth_dest = TSC.TableauAuth(args.username, password, args.dest_site) - - server = TSC.Server(args.server) - dest_server = TSC.Server(args.server) - - with server.auth.sign_in(auth_source): - # Step 2: Verify our source and destination sites exist - found_source_site = False - found_dest_site = False - - found_source_site, found_dest_site = verify_sites(server, args.source_site, args.dest_site) - - # Step 3: get all workbooks with the tag (e.g. 'ready-for-prod') using a filter - req_option = TSC.RequestOptions() - req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.Equals, args.tag)) - all_workbooks, pagination_item = server.workbooks.get(req_option) - - # Step 4: Download workbooks to a temp dir and loop thru them - if len(all_workbooks) > 0: - tmpdir = tempfile.mkdtemp() - - try: - # We got a CSV so let's make a dictionary - if args.csv: - db_info = dict_from_csv(args.csv) - - # Signing into another site requires another server object b/c of the different auth token and site ID - with dest_server.auth.sign_in(auth_dest): - for wb in all_workbooks: - wb_path = server.workbooks.download(wb.id, tmpdir) - - # Step 5: If we have a CSV of data sources then update each workbook db connection per our CSV - if db_info: - source_wb = TDA.Workbook(wb_path) - - # if we have more than one datasource we need to loop - for ds in source_wb.datasources: - for c in ds.connections: - if c.dbname in db_info.keys(): - c.dbname = db_info[c.dbname] - ds.caption = c.dbname - - source_wb.save_as(wb_path) - - # Step 6: Find destination site's default project - dest_sites, _ = dest_server.projects.get() - target_project = next((project for project in dest_sites if project.is_default()), None) - - # Step 7: If default project is found, form a new workbook item and publish - if target_project is not None: - new_workbook = TSC.WorkbookItem(name=wb.name, project_id=target_project.id) - new_workbook = dest_server.workbooks.publish( - new_workbook, wb_path, mode=TSC.Server.PublishMode.Overwrite) - - print("Successfully moved {0} ({1})".format( - new_workbook.name, new_workbook.id)) - else: - error = "The default project could not be found." - raise LookupError(error) - - # Step 8: (if requested) Delete workbook from source site and delete temp directory - if args.delete_source: - server.workbooks.delete(wb.id) - finally: - shutil.rmtree(tmpdir) - - # No workbooks found - else: - print('No workbooks with tag {} found.'.format(args.tag)) - - -# Takes a Tableau Server URL and two site names. Returns true, true if the sites exist on the server - -def verify_sites(server, site1, site2): - found_site1 = False - found_site2 = False - - # Use the Pager to get all the sites - for site in TSC.Pager(server.sites): - if site1.lower() == site.content_url.lower(): - found_site1 = True - if site2.lower() == site.content_url.lower(): - found_site2 = True - - if not found_site1: - error = "Site named {} not found.".format(site1) - raise LookupError(error) - - if not found_site2: - error = "Site named {} not found.".format(site2) - raise LookupError(error) - - return found_site1, found_site2 - - -# Returns a dictionary from a CSV file - -def dict_from_csv(csv_file): - with open(csv_file) as csvfile: - return {value['source_ds']: value['dest_ds'] for value in csv.DictReader(csvfile)} - - -if __name__ == "__main__": - main() From fc2b9bfa8b7ace548166c9d0d1b01d9211e2af16 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Tue, 3 Jan 2017 22:39:47 -0800 Subject: [PATCH 15/37] Python 3.6 Released in Dec (#123) Addresses #122 --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 255151e56..33e133203 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,6 +4,7 @@ python: - "3.3" - "3.4" - "3.5" + - "3.6" - "pypy" # command to install dependencies install: From bfff66584239a40678659fcb870de7edfc03ed90 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Thu, 5 Jan 2017 14:22:14 -0800 Subject: [PATCH 16/37] Implement call to move to highest supported REST API version (#100) Yaay hackathon! This PR adds the ability to detect the highest supported version a given Tableau Server supports. In 10.1 and later this makes use of the `ServerInfo` endpoint, and in others it falls back to `auth.xml` which is guaranteed to be present on all versions of Server that we would care about. If we can't determine the version for some reason, we default to 2.1, which is the last 'major' release of the API (with permissions semantics changes). This currently doesn't have an auto-upgrade flag, that can come in another PR after more discussion --- .../server/endpoint/__init__.py | 2 +- .../server/endpoint/exceptions.py | 4 +++ .../server/endpoint/server_info_endpoint.py | 8 ++++- tableauserverclient/server/server.py | 36 ++++++++++++++++++- test/assets/server_info_404.xml | 7 ++++ test/assets/server_info_auth_info.xml | 12 +++++++ test/test_server_info.py | 31 ++++++++++++++-- 7 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 test/assets/server_info_404.xml create mode 100644 test/assets/server_info_auth_info.xml diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index 63d69510c..d9dca0f42 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -1,7 +1,7 @@ from .auth_endpoint import Auth from .datasources_endpoint import Datasources from .endpoint import Endpoint -from .exceptions import ServerResponseError, MissingRequiredFieldError +from .exceptions import ServerResponseError, MissingRequiredFieldError, ServerInfoEndpointNotFoundError from .groups_endpoint import Groups from .projects_endpoint import Projects from .schedules_endpoint import Schedules diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 7907a6dab..3eadd5ce5 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -24,3 +24,7 @@ def from_response(cls, resp): class MissingRequiredFieldError(Exception): pass + + +class ServerInfoEndpointNotFoundError(Exception): + pass diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index 1fb17f26f..d6b2b7d96 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -1,4 +1,5 @@ from .endpoint import Endpoint +from .exceptions import ServerResponseError, ServerInfoEndpointNotFoundError from ...models import ServerInfoItem import logging @@ -12,6 +13,11 @@ def baseurl(self): def get(self): """ Retrieve the server info for the server. This is an unauthenticated call """ - server_response = self.get_unauthenticated_request(self.baseurl) + try: + server_response = self.get_unauthenticated_request(self.baseurl) + except ServerResponseError as e: + if e.code == "404003": + raise ServerInfoEndpointNotFoundError + server_info = ServerInfoItem.from_response(server_response.content) return server_info diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 2cb08a892..b233377fe 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -1,8 +1,19 @@ +import xml.etree.ElementTree as ET + from .exceptions import NotSignedInError -from .endpoint import Sites, Views, Users, Groups, Workbooks, Datasources, Projects, Auth, Schedules, ServerInfo +from .endpoint import Sites, Views, Users, Groups, Workbooks, Datasources, Projects, Auth, \ + Schedules, ServerInfo, ServerInfoEndpointNotFoundError import requests +_PRODUCT_TO_REST_VERSION = { + '10.0': '2.3', + '9.3': '2.2', + '9.2': '2.1', + '9.1': '2.0', + '9.0': '2.0' +} + class Server(object): class PublishMode: @@ -47,6 +58,29 @@ def _set_auth(self, site_id, user_id, auth_token): self._user_id = user_id self._auth_token = auth_token + def _get_legacy_version(self): + response = self._session.get(self.server_address + "/auth?format=xml") + info_xml = ET.fromstring(response.content) + prod_version = info_xml.find('.//product_version').text + version = _PRODUCT_TO_REST_VERSION.get(prod_version, '2.1') # 2.1 + return version + + def _determine_highest_version(self): + try: + old_version = self.version + self.version = "2.4" + version = self.server_info.get().rest_api_version + except ServerInfoEndpointNotFoundError: + version = self._get_legacy_version() + + finally: + self.version = old_version + + return version + + def use_highest_version(self): + self.version = self._determine_highest_version() + @property def baseurl(self): return "{0}/api/{1}".format(self._server_address, str(self.version)) diff --git a/test/assets/server_info_404.xml b/test/assets/server_info_404.xml new file mode 100644 index 000000000..a23abf9ae --- /dev/null +++ b/test/assets/server_info_404.xml @@ -0,0 +1,7 @@ + + + + Resource Not Found + Unknown resource '/2.4/serverInfo' specified in URI. + + diff --git a/test/assets/server_info_auth_info.xml b/test/assets/server_info_auth_info.xml new file mode 100644 index 000000000..58d9c5baf --- /dev/null +++ b/test/assets/server_info_auth_info.xml @@ -0,0 +1,12 @@ + + +0.31 +0.31 +9.2 +9.3 +9.3.4 +hello.16.1106.2025 +unrestricted +2.6 + + diff --git a/test/test_server_info.py b/test/test_server_info.py index 03e39210f..084e6c91f 100644 --- a/test/test_server_info.py +++ b/test/test_server_info.py @@ -6,21 +6,48 @@ 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_404 = os.path.join(TEST_ASSET_DIR, 'server_info_404.xml') +SERVER_INFO_AUTH_INFO_XML = os.path.join(TEST_ASSET_DIR, 'server_info_auth_info.xml') class ServerInfoTests(unittest.TestCase): def setUp(self): self.server = TSC.Server('http://test') - self.server.version = '2.4' self.baseurl = self.server.server_info.baseurl def test_server_info_get(self): 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.baseurl, text=response_xml) + self.server.version = '2.4' + 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) + + def test_server_info_use_highest_version_downgrades(self): + 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: + # 10.1 serverInfo response + 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_highest_version() + self.assertEqual(self.server.version, '2.2') + + def test_server_info_use_highest_version_upgrades(self): + with open(SERVER_INFO_GET_XML, 'rb') as f: + si_response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.get(self.server.server_address + "/api/2.4/serverInfo", text=si_response_xml) + # Pretend we're old + self.server.version = '2.0' + self.server.use_highest_version() + # Did we upgrade to 2.4? + self.assertEqual(self.server.version, '2.4') From ee7b70b53c6b6cf9219ef15db648334d71e3b08c Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Fri, 6 Jan 2017 15:43:18 -0800 Subject: [PATCH 17/37] Add annotation for endpoints to indicate minimum supported API version (#124) * Add annotation for endpoints to indicate minimum supported API version `endpoint.py` gets a new decorator called `api` that takes a version parameter. This gets normalized and then will check `Server.version` before making the API call. If you are calling an endpoint that is newer than the server version you get a nice error message before it even tries a request against the server! This can be extended in the future to be more complex (eg building a registry of supported methods, etc) but for now this is a huge usability win rather than throwning a Server-returned 404! This PR only adds the decorator, actually identifying the minimum for endpoints will be done in a different PR that needs way more manual testing than this did --- .../server/endpoint/endpoint.py | 40 ++++++++++++++++++- .../server/endpoint/exceptions.py | 4 ++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index e29ab3d82..9f8a6dc3a 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,6 +1,12 @@ -from .exceptions import ServerResponseError +from .exceptions import ServerResponseError, EndpointUnavailableError +from functools import wraps + import logging +try: + from distutils2.version import NormalizedVersion as Version +except ImportError: + from distutils.version import LooseVersion as Version logger = logging.getLogger('tableau.endpoint') @@ -69,3 +75,35 @@ def post_request(self, url, xml_request, content_type='text/xml'): content=xml_request, auth_token=self.parent_srv.auth_token, content_type=content_type) + + +def api(version): + '''Annotate the minimum supported version for an endpoint. + + Checks the version on the server object and compares normalized versions. + It will raise an exception if the server version is > the version specified. + + Args: + `version` minimum version that supports the endpoint. String. + Raises: + EndpointUnavailableError + Returns: + None + + Example: + >>> @api(version="2.3") + >>> def get(self, req_options=None): + >>> ... + ''' + def _decorator(func): + @wraps(func) + def wrapper(self, *args, **kwargs): + server_version = Version(self.parent_srv.version) + minimum_supported = Version(version) + if server_version < minimum_supported: + error = "This endpoint is not available in API version {}. Requires {}".format( + server_version, minimum_supported) + raise EndpointUnavailableError(error) + return func(self, *args, **kwargs) + return wrapper + return _decorator diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 3eadd5ce5..5cb6a06d7 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -28,3 +28,7 @@ class MissingRequiredFieldError(Exception): class ServerInfoEndpointNotFoundError(Exception): pass + + +class EndpointUnavailableError(Exception): + pass From 55bf42057bcd9e14d964b2064f9322c164ba91ff Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Wed, 2 Nov 2016 16:06:55 -0500 Subject: [PATCH 18/37] Test request construction (#91) * GET and POST tests verify headers, body, and query strings coming from `Endpoint` --- test/test_requests.py | 47 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 test/test_requests.py diff --git a/test/test_requests.py b/test/test_requests.py new file mode 100644 index 000000000..3e8011a0a --- /dev/null +++ b/test/test_requests.py @@ -0,0 +1,47 @@ +import unittest + +import requests +import requests_mock + +import tableauserverclient as TSC + + +class RequestTests(unittest.TestCase): + def setUp(self): + self.server = TSC.Server('http://test') + + # 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_make_get_request(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) + resp = self.server.workbooks._make_request(requests.get, + url, + content=None, + request_object=opts, + auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', + content_type='text/xml') + + self.assertEquals(resp.request.query, 'pagenumber=13&pagesize=13') + self.assertEquals(resp.request.headers['x-tableau-auth'], 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM') + self.assertEquals(resp.request.headers['content-type'], 'text/xml') + + 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', + request_object=None, + auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', + content_type='multipart/mixed') + self.assertEquals(resp.request.headers['x-tableau-auth'], 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM') + self.assertEquals(resp.request.headers['content-type'], 'multipart/mixed') + self.assertEquals(resp.request.body, b'1337') From f8b8857902cb10effd422499b12e70f4161501d8 Mon Sep 17 00:00:00 2001 From: Ben Lower Date: Wed, 2 Nov 2016 12:55:06 -0700 Subject: [PATCH 19/37] new sample & new feature instructions Added a new sample (Initialize Server) and added instructions to the readme for how to add new features to the project. --- samples/initialize_server.py | 105 +++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 samples/initialize_server.py diff --git a/samples/initialize_server.py b/samples/initialize_server.py new file mode 100644 index 000000000..2136dc588 --- /dev/null +++ b/samples/initialize_server.py @@ -0,0 +1,105 @@ +#### +# This script sets up a server. It uploads datasources and workbooks from the local filesystem. +# +# By default, all content is published to the Default project on the Default site. +#### + +import tableauserverclient as TSC +import argparse +import getpass +import logging +import glob + +def main(): + parser = argparse.ArgumentParser(description='Initialize a server with content.') + parser.add_argument('--server', '-s', required=True, help='server address') + 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('--site', '-si', required=False, default='Default', help='site to use') + parser.add_argument('--project', '-p', required=False, default='Default', help='project to use') + parser.add_argument('--username', '-u', required=True, help='username to sign into 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() + + password = getpass.getpass("Password: ") + + # 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.TableauAuth(args.username, password) + server = TSC.Server(args.server) + + with server.auth.sign_in(tableau_auth): + + ################################################################################ + # Step 2: Create the site we need only if it doesn't exist + ################################################################################ + print("Checking to see if we need to create the site...") + + all_sites, _ = server.sites.get() + existing_site = next((s for s in all_sites if s.name == args.site), None) + + # Create the site if it doesn't exist + if existing_site is None: + print("Site not found: {0} Creating it...").format(args.site) + new_site = TSC.SiteItem(name=args.site, content_url=args.site.replace(" ", ""), admin_mode=TSC.SiteItem.AdminMode.ContentAndUsers) + server.sites.create(new_site) + else: + print("Site {0} exists. Moving on...").format(args.site) + + + ################################################################################ + # Step 3: Sign-in to our target site + ################################################################################ + print("Starting our content upload...") + server_upload = TSC.Server(args.server) + tableau_auth.site = args.site + + with server_upload.auth.sign_in(tableau_auth): + + ################################################################################ + # Step 4: Create the project we need only if it doesn't exist + ################################################################################ + all_projects, _ = server_upload.projects.get() + project = next((p for p in all_projects if p.name == args.project), None) + + # Create our project if it doesn't exist + if project is None: + print("Project not found: {0} Creating it...").format(args.project) + new_project = TSC.ProjectItem(name=args.project) + project = server_upload.projects.create(new_project) + + ################################################################################ + # Step 5: Set up our content + # Publish datasources to our site and project + # Publish workbooks to our site and project + ################################################################################ + publish_datasources_to_site(server_upload, project, args.datasources_folder) + publish_workbooks_to_site(server_upload, project, args.workbooks_folder) + +def publish_datasources_to_site(server_object, project, folder): + 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)) + + +def publish_workbooks_to_site(server_object, project, folder): + 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)) + + +if __name__ == "__main__": + main() From 8b5c7b16251b3321862e304f0d9047925d3746a3 Mon Sep 17 00:00:00 2001 From: Jared Dominguez Date: Thu, 3 Nov 2016 13:37:51 -0700 Subject: [PATCH 20/37] Initial documentation for TSC (#98) * First cut at API docs * Update jquery version for bootstrap compatibility * Incorporate review feedback * Add pagination docs * Add dev guide * Add docs for populating views and connections * Continue adding to api ref * Edits for existing content * Update readme to point to docs * Incorporate edits from PR --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 099f4ba7d..f1f8f462a 100644 --- a/README.md +++ b/README.md @@ -11,3 +11,4 @@ This repository contains Python source code and sample files. For more information on installing and using TSC, see the documentation: + From 2c8d1a5917f82ae688ddecfe194ee715c7705ee3 Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Wed, 16 Nov 2016 14:54:29 -0800 Subject: [PATCH 21/37] Initial implementation to address #102 and provide datetime objects --- tableauserverclient/datetime_helpers.py | 37 +++++++++++++++++++ tableauserverclient/models/datasource_item.py | 7 +++- .../models/property_decorators.py | 29 +++++++++++++++ tableauserverclient/models/schedule_item.py | 12 +++++- tableauserverclient/models/workbook_item.py | 12 +++++- tableauserverclient/server/request_factory.py | 1 + test/test_datasource_model.py | 32 ++++++++++++++++ test/test_requests.py | 12 +++--- 8 files changed, 133 insertions(+), 9 deletions(-) create mode 100644 tableauserverclient/datetime_helpers.py diff --git a/tableauserverclient/datetime_helpers.py b/tableauserverclient/datetime_helpers.py new file mode 100644 index 000000000..369c1ea86 --- /dev/null +++ b/tableauserverclient/datetime_helpers.py @@ -0,0 +1,37 @@ +import datetime + +try: + from pytz import utc +except ImportError: + # If pytz is not installed, let's polyfill a UTC timezone so it all just works + # 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) + + + # A UTC class. + + class UTC(datetime.tzinfo): + """UTC""" + + def utcoffset(self, dt): + return ZERO + + def tzname(self, dt): + return "UTC" + + def dst(self, dt): + return ZERO + + + utc = UTC() + +TABLEAU_DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" + + +def parse_datetime(date): + return datetime.datetime.strptime(date, TABLEAU_DATE_FORMAT).replace(tzinfo=utc) + + +def format_datetime(date): + return date.astimezone(tz=utc).strftime(TABLEAU_DATE_FORMAT) \ No newline at end of file diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 3ae4c5743..dfd363b29 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as ET from .exceptions import UnpopulatedPropertyError -from .property_decorators import property_not_nullable +from .property_decorators import property_not_nullable, property_is_datetime from .tag_item import TagItem from .. import NAMESPACE @@ -34,6 +34,11 @@ def content_url(self): def created_at(self): return self._created_at + @created_at.setter + @property_is_datetime + def created_at(self, value): + self._created_at = value + @property def id(self): return self._id diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index de8fe8d8c..77612b172 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -1,5 +1,12 @@ +import datetime import re from functools import wraps +from ..datetime_helpers import parse_datetime +try: + basestring +except NameError: + # In case we are in python 3 the string check is different + basestring = str def property_is_enum(enum_type): @@ -99,3 +106,25 @@ def validate_regex_decorator(self, value): return func(self, value) return validate_regex_decorator return wrapper + + +def property_is_datetime(func): + """ Takes the following datetime format and turns it into a datetime object: + + 2016-08-18T18:25:36Z + + Because we return everything with Z as the timezone, we assume everything is in UTC and create + a timezone aware datetime. + """ + + @wraps(func) + def wrapper(self, value): + if isinstance(value, datetime.datetime): + return func(self, value) + if not isinstance(value, basestring): + raise ValueError("Cannot convert {} into a datetime, cannot update {}".format(value.__class__.__name__, + func.__name__)) + + dt = parse_datetime(value) + return func(self, dt) + return wrapper diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index b0f7d1edb..0819e0205 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -2,7 +2,7 @@ from datetime import datetime from .interval_item import IntervalItem, HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval -from .property_decorators import property_is_enum, property_not_nullable, property_is_int +from .property_decorators import property_is_enum, property_not_nullable, property_is_int, property_is_datetime from .. import NAMESPACE @@ -36,6 +36,11 @@ def __init__(self, name, priority, schedule_type, execution_order, interval_item def created_at(self): return self._created_at + @created_at.setter + @property_is_datetime + def created_at(self, value): + self._created_at = value + @property def end_schedule_at(self): return self._end_schedule_at @@ -98,6 +103,11 @@ def state(self, value): def updated_at(self): return self._updated_at + @updated_at.setter + @property_is_datetime + def updated_at(self, value): + self._updated_at = value + def _parse_common_tags(self, schedule_xml): if not isinstance(schedule_xml, ET.Element): schedule_xml = ET.fromstring(schedule_xml).find('.//t:schedule', namespaces=NAMESPACE) diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 9ccde5606..4231da2f3 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as ET from .exceptions import UnpopulatedPropertyError -from .property_decorators import property_not_nullable, property_is_boolean +from .property_decorators import property_not_nullable, property_is_boolean, property_is_datetime from .tag_item import TagItem from .view_item import ViewItem from .. import NAMESPACE @@ -40,6 +40,11 @@ def content_url(self): def created_at(self): return self._created_at + @created_at.setter + @property_is_datetime + def created_at(self, value): + self._created_at = value + @property def id(self): return self._id @@ -81,6 +86,11 @@ def size(self): def updated_at(self): return self._updated_at + @updated_at.setter + @property_is_datetime + def updated_at(self, value): + self._updated_at = value + @property def views(self): if self._views is None: diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 9a9bf53e1..d2c6976e1 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1,3 +1,4 @@ +from ..datetime_helpers import format_datetime import xml.etree.ElementTree as ET from requests.packages.urllib3.fields import RequestField diff --git a/test/test_datasource_model.py b/test/test_datasource_model.py index b43cc3f3d..1d7fa9b92 100644 --- a/test/test_datasource_model.py +++ b/test/test_datasource_model.py @@ -1,3 +1,4 @@ +import datetime import unittest import tableauserverclient as TSC @@ -8,3 +9,34 @@ def test_invalid_project_id(self): datasource = TSC.DatasourceItem("10") with self.assertRaises(ValueError): datasource.project_id = None + + def test_datetime_conversion(self): + datasource = TSC.DatasourceItem("10") + datasource.created_at = "2016-08-18T19:25:36Z" + actual = datasource.created_at + self.assertIsInstance(actual, datetime.datetime) + self.assertEquals(actual.year, 2016) + self.assertEquals(actual.month, 8) + self.assertEquals(actual.day, 18) + self.assertEquals(actual.hour, 19) + self.assertEquals(actual.minute, 25) + self.assertEquals(actual.second, 36) + + def test_datetime_conversion_allows_datetime_passthrough(self): + datasource = TSC.DatasourceItem("10") + now = datetime.datetime.utcnow() + datasource.created_at = now + self.assertEquals(datasource.created_at, now) + + def test_datetime_conversion_is_timezone_aware(self): + datasource = TSC.DatasourceItem("10") + datasource.created_at = "2016-08-18T19:25:36Z" + actual = datasource.created_at + self.assertEquals(actual.utcoffset().seconds, 0) + + def test_datetime_conversion_rejects_things_that_cannot_be_converted(self): + datasource = TSC.DatasourceItem("10") + with self.assertRaises(ValueError): + datasource.created_at = object() + with self.assertRaises(ValueError): + datasource.created_at = "This is so not a datetime" diff --git a/test/test_requests.py b/test/test_requests.py index 3e8011a0a..686a4bbb4 100644 --- a/test/test_requests.py +++ b/test/test_requests.py @@ -28,9 +28,9 @@ def test_make_get_request(self): auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', content_type='text/xml') - self.assertEquals(resp.request.query, 'pagenumber=13&pagesize=13') - self.assertEquals(resp.request.headers['x-tableau-auth'], 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM') - self.assertEquals(resp.request.headers['content-type'], 'text/xml') + self.assertEqual(resp.request.query, 'pagenumber=13&pagesize=13') + self.assertEqual(resp.request.headers['x-tableau-auth'], 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM') + self.assertEqual(resp.request.headers['content-type'], 'text/xml') def test_make_post_request(self): with requests_mock.mock() as m: @@ -42,6 +42,6 @@ def test_make_post_request(self): request_object=None, auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', content_type='multipart/mixed') - self.assertEquals(resp.request.headers['x-tableau-auth'], 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM') - self.assertEquals(resp.request.headers['content-type'], 'multipart/mixed') - self.assertEquals(resp.request.body, b'1337') + 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') From 5dfc155ab1e2ab366c49e256fb704e2b85b9b230 Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Wed, 16 Nov 2016 14:59:29 -0800 Subject: [PATCH 22/37] Fix pep8 failures --- samples/initialize_server.py | 6 ++++-- samples/pagination_sample.py | 1 + tableauserverclient/datetime_helpers.py | 4 +--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/samples/initialize_server.py b/samples/initialize_server.py index 2136dc588..e37317c0e 100644 --- a/samples/initialize_server.py +++ b/samples/initialize_server.py @@ -10,6 +10,7 @@ import logging import glob + def main(): parser = argparse.ArgumentParser(description='Initialize a server with content.') parser.add_argument('--server', '-s', required=True, help='server address') @@ -47,12 +48,12 @@ 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) - new_site = TSC.SiteItem(name=args.site, content_url=args.site.replace(" ", ""), admin_mode=TSC.SiteItem.AdminMode.ContentAndUsers) + new_site = TSC.SiteItem(name=args.site, content_url=args.site.replace(" ", ""), + admin_mode=TSC.SiteItem.AdminMode.ContentAndUsers) server.sites.create(new_site) else: print("Site {0} exists. Moving on...").format(args.site) - ################################################################################ # Step 3: Sign-in to our target site ################################################################################ @@ -82,6 +83,7 @@ def main(): publish_datasources_to_site(server_upload, project, args.datasources_folder) publish_workbooks_to_site(server_upload, project, args.workbooks_folder) + def publish_datasources_to_site(server_object, project, folder): path = folder + '/*.tds*' diff --git a/samples/pagination_sample.py b/samples/pagination_sample.py index 882fc85ad..25effd7b2 100644 --- a/samples/pagination_sample.py +++ b/samples/pagination_sample.py @@ -66,5 +66,6 @@ def main(): # >>> request_options = TSC.RequestOptions(pagesize=1000) # >>> all_workbooks = list(TSC.Pager(server.workbooks, request_options)) + if __name__ == '__main__': main() diff --git a/tableauserverclient/datetime_helpers.py b/tableauserverclient/datetime_helpers.py index 369c1ea86..f8dbf1edd 100644 --- a/tableauserverclient/datetime_helpers.py +++ b/tableauserverclient/datetime_helpers.py @@ -8,7 +8,6 @@ ZERO = datetime.timedelta(0) HOUR = datetime.timedelta(hours=1) - # A UTC class. class UTC(datetime.tzinfo): @@ -23,7 +22,6 @@ def tzname(self, dt): def dst(self, dt): return ZERO - utc = UTC() TABLEAU_DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" @@ -34,4 +32,4 @@ def parse_datetime(date): def format_datetime(date): - return date.astimezone(tz=utc).strftime(TABLEAU_DATE_FORMAT) \ No newline at end of file + return date.astimezone(tz=utc).strftime(TABLEAU_DATE_FORMAT) From 898526d4df60ab209a2d76b6575219625e98ab17 Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Thu, 17 Nov 2016 08:43:08 -0800 Subject: [PATCH 23/37] Remove setters and move to doing the conversion during parsing --- .travis.yml | 2 +- tableauserverclient/datetime_helpers.py | 3 ++ tableauserverclient/models/datasource_item.py | 12 ++---- tableauserverclient/models/schedule_item.py | 21 +++------ tableauserverclient/models/user_item.py | 3 +- tableauserverclient/models/workbook_item.py | 17 ++------ test/test_datasource.py | 17 ++++---- test/test_datasource_model.py | 31 ------------- test/test_group.py | 3 +- test/test_schedule.py | 43 ++++++++++--------- test/test_user.py | 9 ++-- test/test_workbook.py | 17 ++++---- 12 files changed, 67 insertions(+), 111 deletions(-) diff --git a/.travis.yml b/.travis.yml index b0d0b8b7b..255151e56 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,4 +14,4 @@ script: # Tests - python setup.py test # pep8 - disabled for now until we can scrub the files to make sure we pass before turning it on - - pycodestyle . + - pycodestyle tableauserverclient test diff --git a/tableauserverclient/datetime_helpers.py b/tableauserverclient/datetime_helpers.py index f8dbf1edd..32e762385 100644 --- a/tableauserverclient/datetime_helpers.py +++ b/tableauserverclient/datetime_helpers.py @@ -28,6 +28,9 @@ def dst(self, dt): def parse_datetime(date): + if date is None: + return None + return datetime.datetime.strptime(date, TABLEAU_DATE_FORMAT).replace(tzinfo=utc) diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index dfd363b29..2ae469674 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -1,8 +1,9 @@ import xml.etree.ElementTree as ET from .exceptions import UnpopulatedPropertyError -from .property_decorators import property_not_nullable, property_is_datetime +from .property_decorators import property_not_nullable from .tag_item import TagItem from .. import NAMESPACE +from ..datetime_helpers import parse_datetime class DatasourceItem(object): @@ -34,11 +35,6 @@ def content_url(self): def created_at(self): return self._created_at - @created_at.setter - @property_is_datetime - def created_at(self, value): - self._created_at = value - @property def id(self): return self._id @@ -123,8 +119,8 @@ def _parse_element(datasource_xml): name = datasource_xml.get('name', None) datasource_type = datasource_xml.get('type', None) content_url = datasource_xml.get('contentUrl', None) - created_at = datasource_xml.get('createdAt', None) - updated_at = datasource_xml.get('updatedAt', None) + created_at = parse_datetime(datasource_xml.get('createdAt', None)) + updated_at = parse_datetime(datasource_xml.get('updatedAt', None)) tags = None tags_elem = datasource_xml.find('.//t:tags', namespaces=NAMESPACE) diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index 0819e0205..84b070044 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -2,8 +2,9 @@ from datetime import datetime from .interval_item import IntervalItem, HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval -from .property_decorators import property_is_enum, property_not_nullable, property_is_int, property_is_datetime +from .property_decorators import property_is_enum, property_not_nullable, property_is_int from .. import NAMESPACE +from ..datetime_helpers import parse_datetime class ScheduleItem(object): @@ -36,11 +37,6 @@ def __init__(self, name, priority, schedule_type, execution_order, interval_item def created_at(self): return self._created_at - @created_at.setter - @property_is_datetime - def created_at(self, value): - self._created_at = value - @property def end_schedule_at(self): return self._end_schedule_at @@ -103,11 +99,6 @@ def state(self, value): def updated_at(self): return self._updated_at - @updated_at.setter - @property_is_datetime - def updated_at(self, value): - self._updated_at = value - def _parse_common_tags(self, schedule_xml): if not isinstance(schedule_xml, ET.Element): schedule_xml = ET.fromstring(schedule_xml).find('.//t:schedule', namespaces=NAMESPACE) @@ -218,12 +209,12 @@ def _parse_element(schedule_xml): id = schedule_xml.get('id', None) name = schedule_xml.get('name', None) state = schedule_xml.get('state', None) - created_at = schedule_xml.get('createdAt', None) - updated_at = schedule_xml.get('updatedAt', None) + created_at = parse_datetime(schedule_xml.get('createdAt', None)) + updated_at = parse_datetime(schedule_xml.get('updatedAt', None)) schedule_type = schedule_xml.get('type', None) frequency = schedule_xml.get('frequency', None) - next_run_at = schedule_xml.get('nextRunAt', None) - end_schedule_at = schedule_xml.get('endScheduleAt', None) + next_run_at = parse_datetime(schedule_xml.get('nextRunAt', None)) + end_schedule_at = parse_datetime(schedule_xml.get('endScheduleAt', None)) execution_order = schedule_xml.get('executionOrder', None) priority = schedule_xml.get('priority', None) diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 49a048f69..1e4f54af9 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -2,6 +2,7 @@ from .exceptions import UnpopulatedPropertyError from .property_decorators import property_is_enum, property_not_empty, property_not_nullable from .. import NAMESPACE +from ..datetime_helpers import parse_datetime class UserItem(object): @@ -135,7 +136,7 @@ def _parse_element(user_xml): id = user_xml.get('id', None) name = user_xml.get('name', None) site_role = user_xml.get('siteRole', None) - last_login = user_xml.get('lastLogin', None) + last_login = parse_datetime(user_xml.get('lastLogin', None)) external_auth_user_id = user_xml.get('externalAuthUserId', None) fullname = user_xml.get('fullName', None) email = user_xml.get('email', None) diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 4231da2f3..26a3a00c3 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -1,9 +1,10 @@ import xml.etree.ElementTree as ET from .exceptions import UnpopulatedPropertyError -from .property_decorators import property_not_nullable, property_is_boolean, property_is_datetime +from .property_decorators import property_not_nullable, property_is_boolean from .tag_item import TagItem from .view_item import ViewItem from .. import NAMESPACE +from ..datetime_helpers import parse_datetime import copy @@ -40,11 +41,6 @@ def content_url(self): def created_at(self): return self._created_at - @created_at.setter - @property_is_datetime - def created_at(self, value): - self._created_at = value - @property def id(self): return self._id @@ -86,11 +82,6 @@ def size(self): def updated_at(self): return self._updated_at - @updated_at.setter - @property_is_datetime - def updated_at(self, value): - self._updated_at = value - @property def views(self): if self._views is None: @@ -173,8 +164,8 @@ def _parse_element(workbook_xml): id = workbook_xml.get('id', None) name = workbook_xml.get('name', None) content_url = workbook_xml.get('contentUrl', None) - created_at = workbook_xml.get('createdAt', None) - updated_at = workbook_xml.get('updatedAt', None) + created_at = parse_datetime(workbook_xml.get('createdAt', None)) + updated_at = parse_datetime(workbook_xml.get('updatedAt', None)) size = workbook_xml.get('size', None) if size: diff --git a/test/test_datasource.py b/test/test_datasource.py index d01f3cb0f..9a1e07a24 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -2,6 +2,7 @@ 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') @@ -33,8 +34,8 @@ def test_get(self): self.assertEqual('e76a1461-3b1d-4588-bf1b-17551a879ad9', all_datasources[0].id) self.assertEqual('dataengine', all_datasources[0].datasource_type) self.assertEqual('SampleDS', all_datasources[0].content_url) - self.assertEqual('2016-08-11T21:22:40Z', all_datasources[0].created_at) - self.assertEqual('2016-08-11T21:34:17Z', all_datasources[0].updated_at) + 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) @@ -43,8 +44,8 @@ def test_get(self): self.assertEqual('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', all_datasources[1].id) self.assertEqual('dataengine', all_datasources[1].datasource_type) self.assertEqual('Sampledatasource', all_datasources[1].content_url) - self.assertEqual('2016-08-04T21:31:55Z', all_datasources[1].created_at) - self.assertEqual('2016-08-04T21:31:55Z', all_datasources[1].updated_at) + 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) @@ -75,8 +76,8 @@ def test_get_by_id(self): self.assertEqual('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', single_datasource.id) self.assertEqual('dataengine', single_datasource.datasource_type) self.assertEqual('Sampledatasource', single_datasource.content_url) - self.assertEqual('2016-08-04T21:31:55Z', single_datasource.created_at) - self.assertEqual('2016-08-04T21:31:55Z', single_datasource.updated_at) + 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) @@ -125,8 +126,8 @@ def test_publish(self): 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', new_datasource.created_at) - self.assertEqual('2016-08-17T23:37:08Z', new_datasource.updated_at) + 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) diff --git a/test/test_datasource_model.py b/test/test_datasource_model.py index 1d7fa9b92..600587801 100644 --- a/test/test_datasource_model.py +++ b/test/test_datasource_model.py @@ -9,34 +9,3 @@ def test_invalid_project_id(self): datasource = TSC.DatasourceItem("10") with self.assertRaises(ValueError): datasource.project_id = None - - def test_datetime_conversion(self): - datasource = TSC.DatasourceItem("10") - datasource.created_at = "2016-08-18T19:25:36Z" - actual = datasource.created_at - self.assertIsInstance(actual, datetime.datetime) - self.assertEquals(actual.year, 2016) - self.assertEquals(actual.month, 8) - self.assertEquals(actual.day, 18) - self.assertEquals(actual.hour, 19) - self.assertEquals(actual.minute, 25) - self.assertEquals(actual.second, 36) - - def test_datetime_conversion_allows_datetime_passthrough(self): - datasource = TSC.DatasourceItem("10") - now = datetime.datetime.utcnow() - datasource.created_at = now - self.assertEquals(datasource.created_at, now) - - def test_datetime_conversion_is_timezone_aware(self): - datasource = TSC.DatasourceItem("10") - datasource.created_at = "2016-08-18T19:25:36Z" - actual = datasource.created_at - self.assertEquals(actual.utcoffset().seconds, 0) - - def test_datetime_conversion_rejects_things_that_cannot_be_converted(self): - datasource = TSC.DatasourceItem("10") - with self.assertRaises(ValueError): - datasource.created_at = object() - with self.assertRaises(ValueError): - datasource.created_at = "This is so not a datetime" diff --git a/test/test_group.py b/test/test_group.py index ff928bf17..2f7f22701 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -3,6 +3,7 @@ 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') @@ -61,7 +62,7 @@ def test_populate_users(self): 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', user.last_login) + self.assertEqual('2016-08-16T23:17:06Z', format_datetime(user.last_login)) def test_delete(self): with requests_mock.mock() as m: diff --git a/test/test_schedule.py b/test/test_schedule.py index 710bfe2a2..965e414a8 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -1,8 +1,9 @@ +from datetime import time import unittest import os import requests_mock import tableauserverclient as TSC -from datetime import time +from tableauserverclient.datetime_helpers import format_datetime TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -37,19 +38,19 @@ def test_get(self): self.assertEqual("Weekday early mornings", all_schedules[0].name) self.assertEqual("Active", all_schedules[0].state) self.assertEqual(50, all_schedules[0].priority) - self.assertEqual("2016-07-06T20:19:00Z", all_schedules[0].created_at) - self.assertEqual("2016-09-13T11:00:32Z", all_schedules[0].updated_at) + self.assertEqual("2016-07-06T20:19:00Z", format_datetime(all_schedules[0].created_at)) + self.assertEqual("2016-09-13T11:00:32Z", format_datetime(all_schedules[0].updated_at)) self.assertEqual("Extract", all_schedules[0].schedule_type) - self.assertEqual("2016-09-14T11:00:00Z", all_schedules[0].next_run_at) + self.assertEqual("2016-09-14T11:00:00Z", format_datetime(all_schedules[0].next_run_at)) self.assertEqual("bcb79d07-6e47-472f-8a65-d7f51f40c36c", all_schedules[1].id) self.assertEqual("Saturday night", all_schedules[1].name) self.assertEqual("Active", all_schedules[1].state) self.assertEqual(80, all_schedules[1].priority) - self.assertEqual("2016-07-07T20:19:00Z", all_schedules[1].created_at) - self.assertEqual("2016-09-12T16:39:38Z", all_schedules[1].updated_at) + self.assertEqual("2016-07-07T20:19:00Z", format_datetime(all_schedules[1].created_at)) + self.assertEqual("2016-09-12T16:39:38Z", format_datetime(all_schedules[1].updated_at)) self.assertEqual("Subscription", all_schedules[1].schedule_type) - self.assertEqual("2016-09-18T06:00:00Z", all_schedules[1].next_run_at) + self.assertEqual("2016-09-18T06:00:00Z", format_datetime(all_schedules[1].next_run_at)) def test_get_empty(self): with open(GET_EMPTY_XML, "rb") as f: @@ -82,10 +83,10 @@ def test_create_hourly(self): self.assertEqual("hourly-schedule-1", new_schedule.name) self.assertEqual("Active", new_schedule.state) self.assertEqual(50, new_schedule.priority) - self.assertEqual("2016-09-15T20:47:33Z", new_schedule.created_at) - self.assertEqual("2016-09-15T20:47:33Z", new_schedule.updated_at) + self.assertEqual("2016-09-15T20:47:33Z", format_datetime(new_schedule.created_at)) + self.assertEqual("2016-09-15T20:47:33Z", format_datetime(new_schedule.updated_at)) self.assertEqual(TSC.ScheduleItem.Type.Extract, new_schedule.schedule_type) - self.assertEqual("2016-09-16T01:30:00Z", new_schedule.next_run_at) + 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) @@ -105,10 +106,10 @@ def test_create_daily(self): self.assertEqual("daily-schedule-1", new_schedule.name) self.assertEqual("Active", new_schedule.state) self.assertEqual(90, new_schedule.priority) - self.assertEqual("2016-09-15T21:01:09Z", new_schedule.created_at) - self.assertEqual("2016-09-15T21:01:09Z", new_schedule.updated_at) + self.assertEqual("2016-09-15T21:01:09Z", format_datetime(new_schedule.created_at)) + self.assertEqual("2016-09-15T21:01:09Z", format_datetime(new_schedule.updated_at)) self.assertEqual(TSC.ScheduleItem.Type.Subscription, new_schedule.schedule_type) - self.assertEqual("2016-09-16T11:45:00Z", new_schedule.next_run_at) + self.assertEqual("2016-09-16T11:45:00Z", format_datetime(new_schedule.next_run_at)) self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Serial, new_schedule.execution_order) self.assertEqual(time(4, 45), new_schedule.interval_item.start_time) @@ -128,10 +129,10 @@ def test_create_weekly(self): self.assertEqual("weekly-schedule-1", new_schedule.name) self.assertEqual("Active", new_schedule.state) self.assertEqual(80, new_schedule.priority) - self.assertEqual("2016-09-15T21:12:50Z", new_schedule.created_at) - self.assertEqual("2016-09-15T21:12:50Z", new_schedule.updated_at) + self.assertEqual("2016-09-15T21:12:50Z", format_datetime(new_schedule.created_at)) + self.assertEqual("2016-09-15T21:12:50Z", format_datetime(new_schedule.updated_at)) self.assertEqual(TSC.ScheduleItem.Type.Extract, new_schedule.schedule_type) - self.assertEqual("2016-09-16T16:15:00Z", new_schedule.next_run_at) + 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"), @@ -151,10 +152,10 @@ def test_create_monthly(self): self.assertEqual("monthly-schedule-1", new_schedule.name) self.assertEqual("Active", new_schedule.state) self.assertEqual(20, new_schedule.priority) - self.assertEqual("2016-09-15T21:16:56Z", new_schedule.created_at) - self.assertEqual("2016-09-15T21:16:56Z", new_schedule.updated_at) + self.assertEqual("2016-09-15T21:16:56Z", format_datetime(new_schedule.created_at)) + self.assertEqual("2016-09-15T21:16:56Z", format_datetime(new_schedule.updated_at)) self.assertEqual(TSC.ScheduleItem.Type.Extract, new_schedule.schedule_type) - self.assertEqual("2016-10-12T14:00:00Z", new_schedule.next_run_at) + 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) @@ -174,9 +175,9 @@ def test_update(self): self.assertEqual("7bea1766-1543-4052-9753-9d224bc069b5", single_schedule.id) self.assertEqual("weekly-schedule-1", single_schedule.name) self.assertEqual(90, single_schedule.priority) - self.assertEqual("2016-09-15T23:50:02Z", single_schedule.updated_at) + self.assertEqual("2016-09-15T23:50:02Z", format_datetime(single_schedule.updated_at)) self.assertEqual(TSC.ScheduleItem.Type.Extract, single_schedule.schedule_type) - self.assertEqual("2016-09-16T14:00:00Z", single_schedule.next_run_at) + 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"), diff --git a/test/test_user.py b/test/test_user.py index 71ec30207..556cd62a4 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -2,6 +2,7 @@ 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') @@ -38,7 +39,7 @@ def test_get(self): 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', single_user.last_login) + self.assertEqual('2016-08-16T23:17:06Z', format_datetime(single_user.last_login)) 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') @@ -71,7 +72,7 @@ def test_get_by_id(self): 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', single_user.last_login) + 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): @@ -136,8 +137,8 @@ def test_populate_workbooks(self): 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', workbook_list[0].created_at) - self.assertEqual('2016-07-26T20:35:05Z', workbook_list[0].updated_at) + 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) diff --git a/test/test_workbook.py b/test/test_workbook.py index e99d07f81..4ad38b17d 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -2,6 +2,7 @@ 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') @@ -39,8 +40,8 @@ def test_get(self): self.assertEqual('Superstore', all_workbooks[0].content_url) self.assertEqual(False, all_workbooks[0].show_tabs) self.assertEqual(1, all_workbooks[0].size) - self.assertEqual('2016-08-03T20:34:04Z', all_workbooks[0].created_at) - self.assertEqual('2016-08-04T17:56:41Z', all_workbooks[0].updated_at) + self.assertEqual('2016-08-03T20:34:04Z', format_datetime(all_workbooks[0].created_at)) + 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) @@ -50,8 +51,8 @@ def test_get(self): self.assertEqual('SafariSample', all_workbooks[1].content_url) self.assertEqual(False, all_workbooks[1].show_tabs) self.assertEqual(26, all_workbooks[1].size) - self.assertEqual('2016-07-26T20:34:56Z', all_workbooks[1].created_at) - self.assertEqual('2016-07-26T20:35:05Z', all_workbooks[1].updated_at) + self.assertEqual('2016-07-26T20:34:56Z', format_datetime(all_workbooks[1].created_at)) + 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) @@ -83,8 +84,8 @@ def test_get_by_id(self): self.assertEqual('SafariSample', single_workbook.content_url) self.assertEqual(False, single_workbook.show_tabs) self.assertEqual(26, single_workbook.size) - self.assertEqual('2016-07-26T20:34:56Z', single_workbook.created_at) - self.assertEqual('2016-07-26T20:35:05Z', single_workbook.updated_at) + self.assertEqual('2016-07-26T20:34:56Z', format_datetime(single_workbook.created_at)) + 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) @@ -250,8 +251,8 @@ def test_publish(self): 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', new_workbook.created_at) - self.assertEqual('2016-08-18T20:31:34Z', new_workbook.updated_at) + 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) From a15a8f51be49d91d6f1e664811ead482d8253f9f Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Thu, 17 Nov 2016 10:33:35 -0800 Subject: [PATCH 24/37] removing attempt to import pytz --- tableauserverclient/datetime_helpers.py | 31 +++++++++++-------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/tableauserverclient/datetime_helpers.py b/tableauserverclient/datetime_helpers.py index 32e762385..0714eecf4 100644 --- a/tableauserverclient/datetime_helpers.py +++ b/tableauserverclient/datetime_helpers.py @@ -1,28 +1,25 @@ import datetime -try: - from pytz import utc -except ImportError: - # If pytz is not installed, let's polyfill a UTC timezone so it all just works - # 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) - # A UTC class. +# 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) - class UTC(datetime.tzinfo): - """UTC""" +# A UTC class. - def utcoffset(self, dt): - return ZERO +class UTC(datetime.tzinfo): + """UTC""" - def tzname(self, dt): - return "UTC" + def utcoffset(self, dt): + return ZERO - def dst(self, dt): - return ZERO + def tzname(self, dt): + return "UTC" - utc = UTC() + def dst(self, dt): + return ZERO + +utc = UTC() TABLEAU_DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" From ccfd0c2320f8ac120957d9f0c5727f27297dd4ab Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Thu, 17 Nov 2016 11:11:04 -0800 Subject: [PATCH 25/37] fixing yet another pep8 failure --- tableauserverclient/datetime_helpers.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tableauserverclient/datetime_helpers.py b/tableauserverclient/datetime_helpers.py index 0714eecf4..af88d5c71 100644 --- a/tableauserverclient/datetime_helpers.py +++ b/tableauserverclient/datetime_helpers.py @@ -7,6 +7,7 @@ # A UTC class. + class UTC(datetime.tzinfo): """UTC""" @@ -19,6 +20,7 @@ def tzname(self, dt): def dst(self, dt): return ZERO + utc = UTC() TABLEAU_DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" From 5bf5d1fd927b4c1bfa28d1111df50e85a6e4d999 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Fri, 2 Dec 2016 09:38:46 -0800 Subject: [PATCH 26/37] Fix Pager by making UserItem return a list like the other models (#109) Fixes #107 --- tableauserverclient/models/user_item.py | 4 ++-- test/test_user.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 1e4f54af9..2df6764d9 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -119,7 +119,7 @@ def _set_values(self, id, name, site_role, last_login, @classmethod def from_response(cls, resp): - all_user_items = set() + all_user_items = [] parsed_response = ET.fromstring(resp) all_user_xml = parsed_response.findall('.//t:user', namespaces=NAMESPACE) for user_xml in all_user_xml: @@ -128,7 +128,7 @@ def from_response(cls, resp): user_item = cls(name, site_role) user_item._set_values(id, name, site_role, last_login, external_auth_user_id, fullname, email, auth_setting, domain_name) - all_user_items.add(user_item) + all_user_items.append(user_item) return all_user_items @staticmethod diff --git a/test/test_user.py b/test/test_user.py index 556cd62a4..fa8344371 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -54,7 +54,7 @@ def test_get_empty(self): all_users, pagination_item = self.server.users.get() self.assertEqual(0, pagination_item.total_available) - self.assertEqual(set(), all_users) + self.assertEqual([], all_users) def test_get_before_signin(self): self.server._auth_token = None From 65ce46400fd182eeb6290db878161ac93c8c8dc1 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Fri, 9 Dec 2016 10:52:17 -0800 Subject: [PATCH 27/37] Add deprecation warning to site setter too (#97) --- tableauserverclient/models/tableau_auth.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 7670e2812..3b60741d6 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -18,3 +18,10 @@ def site(self): warnings.warn('TableauAuth.site is deprecated, use TableauAuth.site_id instead.', DeprecationWarning) return self.site_id + + @site.setter + def site(self, value): + import warnings + warnings.warn('TableauAuth.site is deprecated, use TableauAuth.site_id instead.', + DeprecationWarning) + self.site_id = value From 9d0c8caf119a63a72bef66dcf6e8ffd73a369082 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Tue, 13 Dec 2016 16:30:22 -0800 Subject: [PATCH 28/37] Fix large downloads (#105) (#111) Large responses were being read into memory. For most calls that's fine, but download could cause the python process to go out of memory due to holding large workbooks or datasources all in memory before writing to disk. Requests has a feature called `iter_content` which when used in combination with `stream=True` on a request will download only the headers, allow us to determine the filename, and then read through the response body in chunks. I picked a size of 1024 bytes, since that's what most of the internet appears to use and I noticed little perf difference between a 1024 byte chunk size and a 1MB chunk size. This is all enabled by exposing the `parameters` argument to `requests.get` by pluming it through our wrapper functions. All tests pass, and manual testing showed the memory problem went away. --- .../server/endpoint/datasources_endpoint.py | 23 +++++++++++-------- .../server/endpoint/endpoint.py | 9 ++++---- .../server/endpoint/workbooks_endpoint.py | 23 +++++++++++-------- 3 files changed, 31 insertions(+), 24 deletions(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index e8e4e4bf6..af8efcd13 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -6,6 +6,7 @@ import logging import copy import cgi +from contextlib import closing # The maximum size of a file that can be published in a single request is 64MB FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB @@ -64,16 +65,18 @@ def download(self, datasource_id, filepath=None): error = "Datasource ID undefined." raise ValueError(error) url = "{0}/{1}/content".format(self.baseurl, datasource_id) - server_response = self.get_request(url) - _, params = cgi.parse_header(server_response.headers['Content-Disposition']) - filename = os.path.basename(params['filename']) - if filepath is None: - filepath = filename - elif os.path.isdir(filepath): - filepath = os.path.join(filepath, filename) - - with open(filepath, 'wb') as f: - f.write(server_response.content) + with closing(self.get_request(url, parameters={'stream': True})) as server_response: + _, params = cgi.parse_header(server_response.headers['Content-Disposition']) + filename = os.path.basename(params['filename']) + if filepath is None: + filepath = filename + elif os.path.isdir(filepath): + filepath = os.path.join(filepath, filename) + + with open(filepath, 'wb') as f: + for chunk in server_response.iter_content(1024): # 1KB + f.write(chunk) + logger.info('Downloaded datasource to {0} (ID: {1})'.format(filepath, datasource_id)) return os.path.abspath(filepath) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index c90b91004..e29ab3d82 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -21,10 +21,11 @@ def _make_common_headers(auth_token, content_type): return headers - def _make_request(self, method, url, content=None, request_object=None, auth_token=None, content_type=None): + def _make_request(self, method, url, content=None, request_object=None, + auth_token=None, content_type=None, parameters=None): if request_object is not None: url = request_object.apply_query_params(url) - parameters = {} + parameters = parameters or {} parameters.update(self.parent_srv.http_options) parameters['headers'] = Endpoint._make_common_headers(auth_token, content_type) @@ -49,9 +50,9 @@ def _check_status(server_response): def get_unauthenticated_request(self, url, request_object=None): return self._make_request(self.parent_srv.session.get, url, request_object=request_object) - def get_request(self, url, request_object=None): + def get_request(self, url, request_object=None, parameters=None): return self._make_request(self.parent_srv.session.get, url, auth_token=self.parent_srv.auth_token, - request_object=request_object) + request_object=request_object, parameters=parameters) def delete_request(self, url): # We don't return anything for a delete diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 6aabc6029..eb185476e 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -7,6 +7,7 @@ import logging import copy import cgi +from contextlib import closing # The maximum size of a file that can be published in a single request is 64MB FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB @@ -92,16 +93,18 @@ def download(self, workbook_id, filepath=None): error = "Workbook ID undefined." raise ValueError(error) url = "{0}/{1}/content".format(self.baseurl, workbook_id) - server_response = self.get_request(url) - _, params = cgi.parse_header(server_response.headers['Content-Disposition']) - filename = os.path.basename(params['filename']) - if filepath is None: - filepath = filename - elif os.path.isdir(filepath): - filepath = os.path.join(filepath, filename) - - with open(filepath, 'wb') as f: - f.write(server_response.content) + + with closing(self.get_request(url, parameters={"stream": True})) as server_response: + _, params = cgi.parse_header(server_response.headers['Content-Disposition']) + filename = os.path.basename(params['filename']) + if filepath is None: + filepath = filename + elif os.path.isdir(filepath): + filepath = os.path.join(filepath, filename) + + with open(filepath, 'wb') as f: + for chunk in server_response.iter_content(1024): # 1KB + f.write(chunk) logger.info('Downloaded workbook to {0} (ID: {1})'.format(filepath, workbook_id)) return os.path.abspath(filepath) From 01235eaba0408e9dcbe9b6a487eea76be3e82da6 Mon Sep 17 00:00:00 2001 From: Hugo Stijns Date: Fri, 23 Dec 2016 20:20:43 +0100 Subject: [PATCH 29/37] Enhancement #117: Add support for the oAuth flag * Add support for the oAuth flag when publishing workbooks and data sources --- tableauserverclient/models/connection_credentials.py | 12 +++++++++++- tableauserverclient/server/request_factory.py | 6 ++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/models/connection_credentials.py b/tableauserverclient/models/connection_credentials.py index d823b0b7f..8c3a77925 100644 --- a/tableauserverclient/models/connection_credentials.py +++ b/tableauserverclient/models/connection_credentials.py @@ -9,10 +9,11 @@ class ConnectionCredentials(object): """ - def __init__(self, name, password, embed=True): + def __init__(self, name, password, embed=True, oauth=False): self.name = name self.password = password self.embed = embed + self.oauth = oauth @property def embed(self): @@ -22,3 +23,12 @@ def embed(self): @property_is_boolean def embed(self, value): self._embed = value + + @property + def oauth(self): + return self._oauth + + @oauth.setter + @property_is_boolean + def oauth(self, value): + self._oauth = value diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index d2c6976e1..db82b52aa 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -42,6 +42,9 @@ def _generate_xml(self, datasource_item, connection_credentials=None): 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' + + if connection_credentials.oauth: + credentials_element.attrib['oAuth'] = 'true' return ET.tostring(xml_request) def update_req(self, datasource_item): @@ -279,6 +282,9 @@ def _generate_xml(self, workbook_item, connection_credentials=None): 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' + + if connection_credentials.oauth: + credentials_element.attrib['oAuth'] = 'true' return ET.tostring(xml_request) def update_req(self, workbook_item): From f310f3d4bcc3c099acf3a984bc689fcc185e2125 Mon Sep 17 00:00:00 2001 From: Ben Lower Date: Wed, 28 Dec 2016 15:12:11 -0800 Subject: [PATCH 30/37] New sample: Migrate with Datasources This sample shows how to migrate workbooks from one site to another and change their datasources using the Tableau Document API --- samples/migrate_with_datasources.py | 147 ++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 samples/migrate_with_datasources.py diff --git a/samples/migrate_with_datasources.py b/samples/migrate_with_datasources.py new file mode 100644 index 000000000..65988ee38 --- /dev/null +++ b/samples/migrate_with_datasources.py @@ -0,0 +1,147 @@ +#### +# This script will move workbooks from one site to another. It will find workbooks with a given tag, download them, +# and then publish them to the destination site. Before moving the workbooks, we (optionally) modify them to point to +# production datasources based on information contained in a CSV file. +# +# If a CSV file is used, it is assumed to have two columns: source_ds and dest_ds. +# +# To run the script, you must have installed Python 2.7.9 or later. +#### + + +import argparse +import csv +import getpass +import logging +import shutil +import tableaudocumentapi as TDA +import tableauserverclient as TSC +import tempfile + + +def main(): + parser = argparse.ArgumentParser(description='Move workbooks with the given tag from one project to another.') + parser.add_argument('--server', '-s', required=True, help='server address') + parser.add_argument('--username', '-u', required=True, help='username to sign into server') + parser.add_argument('--source-site', '-ss', required=True, help='source site to get workbooks from') + parser.add_argument('--dest-site', '-ds', required=True, help='destination site to copy workbooks to') + parser.add_argument('--tag', '-t', required=True, help='tag to search for') + parser.add_argument('--csv', '-c', required=False, help='CSV file containing database info') + parser.add_argument('--delete-source', '-d', required=False, help='use true to delete source wbs after migration') + 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() + db_info = None + password = getpass.getpass("Password: ") + + # 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 twice because the destination site has a + # different site id and requires second server object + auth_source = TSC.TableauAuth(args.username, password, args.source_site) + auth_dest = TSC.TableauAuth(args.username, password, args.dest_site) + + server = TSC.Server(args.server) + dest_server = TSC.Server(args.server) + + with server.auth.sign_in(auth_source): + # Step 2: Verify our source and destination sites exist + found_source_site = False + found_dest_site = False + + found_source_site, found_dest_site = verify_sites(server, args.source_site, args.dest_site) + + # Step 3: get all workbooks with the tag (e.g. 'ready-for-prod') using a filter + req_option = TSC.RequestOptions() + req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.Equals, args.tag)) + all_workbooks, pagination_item = server.workbooks.get(req_option) + + # Step 4: Download workbooks to a temp dir and loop thru them + if len(all_workbooks) > 0: + tmpdir = tempfile.mkdtemp() + + try: + # We got a CSV so let's make a dictionary + if args.csv: + db_info = dict_from_csv(args.csv) + + # Signing into another site requires another server object b/c of the different auth token and site ID + with dest_server.auth.sign_in(auth_dest): + for wb in all_workbooks: + wb_path = server.workbooks.download(wb.id, tmpdir) + + # Step 5: If we have a CSV of data sources then update each workbook db connection per our CSV + if db_info: + source_wb = TDA.Workbook(wb_path) + + # if we have more than one datasource we need to loop + for ds in source_wb.datasources: + for c in ds.connections: + if c.dbname in db_info.keys(): + c.dbname = db_info[c.dbname] + ds.caption = c.dbname + + source_wb.save_as(wb_path) + + # Step 6: Find destination site's default project + dest_sites, _ = dest_server.projects.get() + target_project = next((project for project in dest_sites if project.is_default()), None) + + # Step 7: If default project is found, form a new workbook item and publish + if target_project is not None: + new_workbook = TSC.WorkbookItem(name=wb.name, project_id=target_project.id) + new_workbook = dest_server.workbooks.publish( + new_workbook, wb_path, mode=TSC.Server.PublishMode.Overwrite) + + print("Successfully moved {0} ({1})".format( + new_workbook.name, new_workbook.id)) + else: + error = "The default project could not be found." + raise LookupError(error) + + # Step 8: (if requested) Delete workbook from source site and delete temp directory + if args.delete_source: + server.workbooks.delete(wb.id) + finally: + shutil.rmtree(tmpdir) + + # No workbooks found + else: + print('No workbooks with tag {} found.'.format(args.tag)) + + +# Takes a Tableau Server URL and two site names. Returns true, true if the sites exist on the server + +def verify_sites(server, site1, site2): + found_site1 = False + found_site2 = False + + # Use the Pager to get all the sites + for site in TSC.Pager(server.sites): + if site1.lower() == site.content_url.lower(): + found_site1 = True + if site2.lower() == site.content_url.lower(): + found_site2 = True + + if not found_site1: + error = "Site named {} not found.".format(site1) + raise LookupError(error) + + if not found_site2: + error = "Site named {} not found.".format(site2) + raise LookupError(error) + + return found_site1, found_site2 + + +# Returns a dictionary from a CSV file + +def dict_from_csv(csv_file): + with open(csv_file) as csvfile: + return {value['source_ds']: value['dest_ds'] for value in csv.DictReader(csvfile)} + + +if __name__ == "__main__": + main() From 2e31644523113984d894c7bdc0b834940e987dec Mon Sep 17 00:00:00 2001 From: Ben Lower Date: Wed, 28 Dec 2016 15:15:20 -0800 Subject: [PATCH 31/37] Revert "New sample: Migrate with Datasources" This reverts commit 49922e1ff2bf17dbb613dc512fd45c8d951ffbaf. --- samples/migrate_with_datasources.py | 147 ---------------------------- 1 file changed, 147 deletions(-) delete mode 100644 samples/migrate_with_datasources.py diff --git a/samples/migrate_with_datasources.py b/samples/migrate_with_datasources.py deleted file mode 100644 index 65988ee38..000000000 --- a/samples/migrate_with_datasources.py +++ /dev/null @@ -1,147 +0,0 @@ -#### -# This script will move workbooks from one site to another. It will find workbooks with a given tag, download them, -# and then publish them to the destination site. Before moving the workbooks, we (optionally) modify them to point to -# production datasources based on information contained in a CSV file. -# -# If a CSV file is used, it is assumed to have two columns: source_ds and dest_ds. -# -# To run the script, you must have installed Python 2.7.9 or later. -#### - - -import argparse -import csv -import getpass -import logging -import shutil -import tableaudocumentapi as TDA -import tableauserverclient as TSC -import tempfile - - -def main(): - parser = argparse.ArgumentParser(description='Move workbooks with the given tag from one project to another.') - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--username', '-u', required=True, help='username to sign into server') - parser.add_argument('--source-site', '-ss', required=True, help='source site to get workbooks from') - parser.add_argument('--dest-site', '-ds', required=True, help='destination site to copy workbooks to') - parser.add_argument('--tag', '-t', required=True, help='tag to search for') - parser.add_argument('--csv', '-c', required=False, help='CSV file containing database info') - parser.add_argument('--delete-source', '-d', required=False, help='use true to delete source wbs after migration') - 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() - db_info = None - password = getpass.getpass("Password: ") - - # 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 twice because the destination site has a - # different site id and requires second server object - auth_source = TSC.TableauAuth(args.username, password, args.source_site) - auth_dest = TSC.TableauAuth(args.username, password, args.dest_site) - - server = TSC.Server(args.server) - dest_server = TSC.Server(args.server) - - with server.auth.sign_in(auth_source): - # Step 2: Verify our source and destination sites exist - found_source_site = False - found_dest_site = False - - found_source_site, found_dest_site = verify_sites(server, args.source_site, args.dest_site) - - # Step 3: get all workbooks with the tag (e.g. 'ready-for-prod') using a filter - req_option = TSC.RequestOptions() - req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.Equals, args.tag)) - all_workbooks, pagination_item = server.workbooks.get(req_option) - - # Step 4: Download workbooks to a temp dir and loop thru them - if len(all_workbooks) > 0: - tmpdir = tempfile.mkdtemp() - - try: - # We got a CSV so let's make a dictionary - if args.csv: - db_info = dict_from_csv(args.csv) - - # Signing into another site requires another server object b/c of the different auth token and site ID - with dest_server.auth.sign_in(auth_dest): - for wb in all_workbooks: - wb_path = server.workbooks.download(wb.id, tmpdir) - - # Step 5: If we have a CSV of data sources then update each workbook db connection per our CSV - if db_info: - source_wb = TDA.Workbook(wb_path) - - # if we have more than one datasource we need to loop - for ds in source_wb.datasources: - for c in ds.connections: - if c.dbname in db_info.keys(): - c.dbname = db_info[c.dbname] - ds.caption = c.dbname - - source_wb.save_as(wb_path) - - # Step 6: Find destination site's default project - dest_sites, _ = dest_server.projects.get() - target_project = next((project for project in dest_sites if project.is_default()), None) - - # Step 7: If default project is found, form a new workbook item and publish - if target_project is not None: - new_workbook = TSC.WorkbookItem(name=wb.name, project_id=target_project.id) - new_workbook = dest_server.workbooks.publish( - new_workbook, wb_path, mode=TSC.Server.PublishMode.Overwrite) - - print("Successfully moved {0} ({1})".format( - new_workbook.name, new_workbook.id)) - else: - error = "The default project could not be found." - raise LookupError(error) - - # Step 8: (if requested) Delete workbook from source site and delete temp directory - if args.delete_source: - server.workbooks.delete(wb.id) - finally: - shutil.rmtree(tmpdir) - - # No workbooks found - else: - print('No workbooks with tag {} found.'.format(args.tag)) - - -# Takes a Tableau Server URL and two site names. Returns true, true if the sites exist on the server - -def verify_sites(server, site1, site2): - found_site1 = False - found_site2 = False - - # Use the Pager to get all the sites - for site in TSC.Pager(server.sites): - if site1.lower() == site.content_url.lower(): - found_site1 = True - if site2.lower() == site.content_url.lower(): - found_site2 = True - - if not found_site1: - error = "Site named {} not found.".format(site1) - raise LookupError(error) - - if not found_site2: - error = "Site named {} not found.".format(site2) - raise LookupError(error) - - return found_site1, found_site2 - - -# Returns a dictionary from a CSV file - -def dict_from_csv(csv_file): - with open(csv_file) as csvfile: - return {value['source_ds']: value['dest_ds'] for value in csv.DictReader(csvfile)} - - -if __name__ == "__main__": - main() From f172490c9b9a453aac5cd4a68bfedea3019e22fb Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Tue, 3 Jan 2017 22:39:47 -0800 Subject: [PATCH 32/37] Python 3.6 Released in Dec (#123) Addresses #122 --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 255151e56..33e133203 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,6 +4,7 @@ python: - "3.3" - "3.4" - "3.5" + - "3.6" - "pypy" # command to install dependencies install: From 8a0112e73b44bf4a875c266d929d74241d347fea Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Thu, 5 Jan 2017 14:22:14 -0800 Subject: [PATCH 33/37] Implement call to move to highest supported REST API version (#100) Yaay hackathon! This PR adds the ability to detect the highest supported version a given Tableau Server supports. In 10.1 and later this makes use of the `ServerInfo` endpoint, and in others it falls back to `auth.xml` which is guaranteed to be present on all versions of Server that we would care about. If we can't determine the version for some reason, we default to 2.1, which is the last 'major' release of the API (with permissions semantics changes). This currently doesn't have an auto-upgrade flag, that can come in another PR after more discussion --- .../server/endpoint/__init__.py | 2 +- .../server/endpoint/exceptions.py | 4 +++ .../server/endpoint/server_info_endpoint.py | 8 ++++- tableauserverclient/server/server.py | 36 ++++++++++++++++++- test/assets/server_info_404.xml | 7 ++++ test/assets/server_info_auth_info.xml | 12 +++++++ test/test_server_info.py | 31 ++++++++++++++-- 7 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 test/assets/server_info_404.xml create mode 100644 test/assets/server_info_auth_info.xml diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index 63d69510c..d9dca0f42 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -1,7 +1,7 @@ from .auth_endpoint import Auth from .datasources_endpoint import Datasources from .endpoint import Endpoint -from .exceptions import ServerResponseError, MissingRequiredFieldError +from .exceptions import ServerResponseError, MissingRequiredFieldError, ServerInfoEndpointNotFoundError from .groups_endpoint import Groups from .projects_endpoint import Projects from .schedules_endpoint import Schedules diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 7907a6dab..3eadd5ce5 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -24,3 +24,7 @@ def from_response(cls, resp): class MissingRequiredFieldError(Exception): pass + + +class ServerInfoEndpointNotFoundError(Exception): + pass diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index 1fb17f26f..d6b2b7d96 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -1,4 +1,5 @@ from .endpoint import Endpoint +from .exceptions import ServerResponseError, ServerInfoEndpointNotFoundError from ...models import ServerInfoItem import logging @@ -12,6 +13,11 @@ def baseurl(self): def get(self): """ Retrieve the server info for the server. This is an unauthenticated call """ - server_response = self.get_unauthenticated_request(self.baseurl) + try: + server_response = self.get_unauthenticated_request(self.baseurl) + except ServerResponseError as e: + if e.code == "404003": + raise ServerInfoEndpointNotFoundError + server_info = ServerInfoItem.from_response(server_response.content) return server_info diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 2cb08a892..b233377fe 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -1,8 +1,19 @@ +import xml.etree.ElementTree as ET + from .exceptions import NotSignedInError -from .endpoint import Sites, Views, Users, Groups, Workbooks, Datasources, Projects, Auth, Schedules, ServerInfo +from .endpoint import Sites, Views, Users, Groups, Workbooks, Datasources, Projects, Auth, \ + Schedules, ServerInfo, ServerInfoEndpointNotFoundError import requests +_PRODUCT_TO_REST_VERSION = { + '10.0': '2.3', + '9.3': '2.2', + '9.2': '2.1', + '9.1': '2.0', + '9.0': '2.0' +} + class Server(object): class PublishMode: @@ -47,6 +58,29 @@ def _set_auth(self, site_id, user_id, auth_token): self._user_id = user_id self._auth_token = auth_token + def _get_legacy_version(self): + response = self._session.get(self.server_address + "/auth?format=xml") + info_xml = ET.fromstring(response.content) + prod_version = info_xml.find('.//product_version').text + version = _PRODUCT_TO_REST_VERSION.get(prod_version, '2.1') # 2.1 + return version + + def _determine_highest_version(self): + try: + old_version = self.version + self.version = "2.4" + version = self.server_info.get().rest_api_version + except ServerInfoEndpointNotFoundError: + version = self._get_legacy_version() + + finally: + self.version = old_version + + return version + + def use_highest_version(self): + self.version = self._determine_highest_version() + @property def baseurl(self): return "{0}/api/{1}".format(self._server_address, str(self.version)) diff --git a/test/assets/server_info_404.xml b/test/assets/server_info_404.xml new file mode 100644 index 000000000..a23abf9ae --- /dev/null +++ b/test/assets/server_info_404.xml @@ -0,0 +1,7 @@ + + + + Resource Not Found + Unknown resource '/2.4/serverInfo' specified in URI. + + diff --git a/test/assets/server_info_auth_info.xml b/test/assets/server_info_auth_info.xml new file mode 100644 index 000000000..58d9c5baf --- /dev/null +++ b/test/assets/server_info_auth_info.xml @@ -0,0 +1,12 @@ + + +0.31 +0.31 +9.2 +9.3 +9.3.4 +hello.16.1106.2025 +unrestricted +2.6 + + diff --git a/test/test_server_info.py b/test/test_server_info.py index 03e39210f..084e6c91f 100644 --- a/test/test_server_info.py +++ b/test/test_server_info.py @@ -6,21 +6,48 @@ 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_404 = os.path.join(TEST_ASSET_DIR, 'server_info_404.xml') +SERVER_INFO_AUTH_INFO_XML = os.path.join(TEST_ASSET_DIR, 'server_info_auth_info.xml') class ServerInfoTests(unittest.TestCase): def setUp(self): self.server = TSC.Server('http://test') - self.server.version = '2.4' self.baseurl = self.server.server_info.baseurl def test_server_info_get(self): 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.baseurl, text=response_xml) + self.server.version = '2.4' + 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) + + def test_server_info_use_highest_version_downgrades(self): + 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: + # 10.1 serverInfo response + 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_highest_version() + self.assertEqual(self.server.version, '2.2') + + def test_server_info_use_highest_version_upgrades(self): + with open(SERVER_INFO_GET_XML, 'rb') as f: + si_response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.get(self.server.server_address + "/api/2.4/serverInfo", text=si_response_xml) + # Pretend we're old + self.server.version = '2.0' + self.server.use_highest_version() + # Did we upgrade to 2.4? + self.assertEqual(self.server.version, '2.4') From f7da0db98ac0f9567b27da77cdeaba6be9d0e3ab Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Fri, 6 Jan 2017 15:43:18 -0800 Subject: [PATCH 34/37] Add annotation for endpoints to indicate minimum supported API version (#124) * Add annotation for endpoints to indicate minimum supported API version `endpoint.py` gets a new decorator called `api` that takes a version parameter. This gets normalized and then will check `Server.version` before making the API call. If you are calling an endpoint that is newer than the server version you get a nice error message before it even tries a request against the server! This can be extended in the future to be more complex (eg building a registry of supported methods, etc) but for now this is a huge usability win rather than throwning a Server-returned 404! This PR only adds the decorator, actually identifying the minimum for endpoints will be done in a different PR that needs way more manual testing than this did --- .../server/endpoint/endpoint.py | 40 ++++++++++++++++++- .../server/endpoint/exceptions.py | 4 ++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index e29ab3d82..9f8a6dc3a 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,6 +1,12 @@ -from .exceptions import ServerResponseError +from .exceptions import ServerResponseError, EndpointUnavailableError +from functools import wraps + import logging +try: + from distutils2.version import NormalizedVersion as Version +except ImportError: + from distutils.version import LooseVersion as Version logger = logging.getLogger('tableau.endpoint') @@ -69,3 +75,35 @@ def post_request(self, url, xml_request, content_type='text/xml'): content=xml_request, auth_token=self.parent_srv.auth_token, content_type=content_type) + + +def api(version): + '''Annotate the minimum supported version for an endpoint. + + Checks the version on the server object and compares normalized versions. + It will raise an exception if the server version is > the version specified. + + Args: + `version` minimum version that supports the endpoint. String. + Raises: + EndpointUnavailableError + Returns: + None + + Example: + >>> @api(version="2.3") + >>> def get(self, req_options=None): + >>> ... + ''' + def _decorator(func): + @wraps(func) + def wrapper(self, *args, **kwargs): + server_version = Version(self.parent_srv.version) + minimum_supported = Version(version) + if server_version < minimum_supported: + error = "This endpoint is not available in API version {}. Requires {}".format( + server_version, minimum_supported) + raise EndpointUnavailableError(error) + return func(self, *args, **kwargs) + return wrapper + return _decorator diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 3eadd5ce5..5cb6a06d7 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -28,3 +28,7 @@ class MissingRequiredFieldError(Exception): class ServerInfoEndpointNotFoundError(Exception): pass + + +class EndpointUnavailableError(Exception): + pass From 8dfeabfb027cec431fa258343339b0a8b16d7678 Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Wed, 11 Jan 2017 11:52:07 -0800 Subject: [PATCH 35/37] Prepping for release 0.3 --- CHANGELOG.md | 14 ++++++++++++++ CONTRIBUTORS.md | 1 + setup.py | 2 +- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9aa404ed..2ba927a08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## 0.3 (11 January 2017) + +* Return DateTime objects instead of strings (#102) +* UserItem now is compatible with Pager (#107, #109) +* Deprecated site_id in favor of site (#97) +* Improved handling of large downloads (#105, #111) +* Added support for oAuth when publishing (#117) +* Added Testing against Py36 (#122, #123) +* Added Version Checking to use highest supported REST api version (#100) +* Added Infrastructure for throwing error if trying to do something that is not supported by REST api version (#124) +* Various Code Cleanup +* Added Documentation (#98) +* Improved Test Infrastructure (#91) + ## 0.2 (02 November 2016) * Added Initial Schedules Support (#48) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index c97e9301d..8e60b6e59 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -5,6 +5,7 @@ The following people have contributed to this project to make it possible, and w ## Contributors * [geordielad](https://github.com/geordielad) +* [Hugo Stijns)(https://github.com/hugoboos) * [kovner](https://github.com/kovner) diff --git a/setup.py b/setup.py index e4214aa70..ac932390d 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name='tableauserverclient', - version='0.2', + version='0.3', author='Tableau', author_email='github@tableau.com', url='https://github.com/tableau/server-client-python', From 9ab86de5419317248c4279a95e41249b54705481 Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Wed, 11 Jan 2017 12:20:20 -0800 Subject: [PATCH 36/37] site_id is prefered, not site. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ba927a08..8505d4c90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ * Return DateTime objects instead of strings (#102) * UserItem now is compatible with Pager (#107, #109) -* Deprecated site_id in favor of site (#97) +* Deprecated site in favor of site_id (#97) * Improved handling of large downloads (#105, #111) * Added support for oAuth when publishing (#117) * Added Testing against Py36 (#122, #123) From fec87556960859d3c3dac07e19fad9180e85d7e8 Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Wed, 11 Jan 2017 13:58:13 -0800 Subject: [PATCH 37/37] Adding missing contributors --- CONTRIBUTORS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 8e60b6e59..553a3c2b9 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -15,3 +15,5 @@ The following people have contributed to this project to make it possible, and w * [lgraber](https://github.com/lgraber) * [t8y8](https://github.com/t8y8) * [RussTheAerialist](https://github.com/RussTheAerialist) +* [Ben Lower](https://github.com/benlower) +* [Jared Dominguez](https://github.com/jdomingu)