From 55bf42057bcd9e14d964b2064f9322c164ba91ff Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Wed, 2 Nov 2016 16:06:55 -0500 Subject: [PATCH 01/20] 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 02/20] 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 03/20] 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 04/20] 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 05/20] 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 06/20] 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 07/20] 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 08/20] 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 09/20] 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 10/20] 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 11/20] 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 12/20] 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 13/20] 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 14/20] 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 15/20] 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 16/20] 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 17/20] 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 18/20] 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 19/20] 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 20/20] 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)