From 44ca620818862a70c6a8e6e1107eebe16ea20f30 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Wed, 27 Jul 2016 11:20:39 +0800 Subject: [PATCH 01/11] Basic connection class retargeting (#65) - Allow modification of dbclass attribute - Only allow updates to a valid (known) dbclass type. That is, you can change the database type. Other attributes (like ssl and all the various auth modes are not yet supported) Now you can take: mysql-test-db.yourcompany.com, and move it to postgres-prod-db.yourcompany.com and update the dbclass to match! --- tableaudocumentapi/connection.py | 10 ++++++ tableaudocumentapi/dbclass.py | 60 ++++++++++++++++++++++++++++++++ test/bvt.py | 7 ++++ 3 files changed, 77 insertions(+) create mode 100644 tableaudocumentapi/dbclass.py diff --git a/tableaudocumentapi/connection.py b/tableaudocumentapi/connection.py index 3e64c1b..ab4dcbb 100644 --- a/tableaudocumentapi/connection.py +++ b/tableaudocumentapi/connection.py @@ -3,6 +3,7 @@ # Connection - A class for writing connections to Tableau files # ############################################################################### +from tableaudocumentapi.dbclass import is_valid_dbclass class Connection(object): @@ -111,3 +112,12 @@ def authentication(self): @property def dbclass(self): return self._class + + @dbclass.setter + def dbclass(self, value): + + if not is_valid_dbclass(value): + raise AttributeError("'{}' is not a valid database type".format(value)) + + self._class = value + self._connectionXML.set('dbclass', value) diff --git a/tableaudocumentapi/dbclass.py b/tableaudocumentapi/dbclass.py new file mode 100644 index 0000000..b466452 --- /dev/null +++ b/tableaudocumentapi/dbclass.py @@ -0,0 +1,60 @@ + + +KNOWN_DB_CLASSES = ('msaccess', + 'msolap', + 'bigquery', + 'asterncluster', + 'bigsql', + 'aurora', + 'awshadoophive', + 'dataengine', + 'DataStax', + 'db2', + 'essbase', + 'exasolution', + 'excel', + 'excel-direct', + 'excel-reader', + 'firebird', + 'powerpivot', + 'genericodbc', + 'google-analytics', + 'googlecloudsql', + 'google-sheets', + 'greenplum', + 'saphana', + 'hadoophive', + 'hortonworkshadoophive', + 'maprhadoophive', + 'marklogic', + 'memsql', + 'mysql', + 'netezza', + 'oracle', + 'paraccel', + 'postgres', + 'progressopenedge', + 'redshift', + 'snowflake', + 'spark', + 'splunk', + 'kognitio', + 'sqlserver', + 'salesforce', + 'sapbw', + 'sybasease', + 'sybaseiq', + 'tbio', + 'teradata', + 'vectorwise', + 'vertica', + 'denormalized-cube', + 'csv', + 'textscan', + 'webdata', + 'webdata-direct', + 'cubeextract') + + +def is_valid_dbclass(dbclass): + return dbclass in KNOWN_DB_CLASSES diff --git a/test/bvt.py b/test/bvt.py index 6a7cdf8..d26b91a 100644 --- a/test/bvt.py +++ b/test/bvt.py @@ -64,6 +64,13 @@ def test_can_write_attributes_to_connection(self): self.assertEqual(conn.username, 'bob') self.assertEqual(conn.server, 'mssql2014.test.tsi.lan') + def test_bad_dbclass_rasies_attribute_error(self): + conn = Connection(self.connection) + conn.dbclass = 'sqlserver' + self.assertEqual(conn.dbclass, 'sqlserver') + with self.assertRaises(AttributeError): + conn.dbclass = 'NotReal' + class DatasourceModelTests(unittest.TestCase): From 7041c9e0a64bee2da844564bb4a556efaf37ddb0 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Wed, 27 Jul 2016 18:51:21 +0800 Subject: [PATCH 02/11] Bump test coverage in fields: cover datatype and role attributes (#64) * Bump test coverage up a bit in fields * Fix pep8 violation, and it fixed a few other things automatically as well --- test/test_datasource.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/test/test_datasource.py b/test/test_datasource.py index bf51746..6ea03ea 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -19,6 +19,7 @@ class DataSourceFieldsTDS(unittest.TestCase): + def setUp(self): self.ds = Datasource.from_file(TEST_TDS_FILE) @@ -51,11 +52,19 @@ def test_datasource_field_is_quantitative(self): def test_datasource_field_is_ordinal(self): self.assertTrue(self.ds.fields['[x]'].is_ordinal) + def test_datasource_field_datatype(self): + self.assertEqual(self.ds.fields['[x]'].datatype, 'integer') + + def test_datasource_field_role(self): + self.assertEqual(self.ds.fields['[x]'].role, 'measure') + class DataSourceFieldsTWB(unittest.TestCase): + def setUp(self): self.wb = Workbook(TEST_TWB_FILE) - self.ds = self.wb.datasources[0] # Assume the first datasource in the file + # Assume the first datasource in the file + self.ds = self.wb.datasources[0] def test_datasource_fields_loaded_in_workbook(self): self.assertIsNotNone(self.ds.fields) @@ -63,9 +72,11 @@ def test_datasource_fields_loaded_in_workbook(self): class DataSourceFieldsFoundIn(unittest.TestCase): + def setUp(self): self.wb = Workbook(TEST_TWB_FILE) - self.ds = self.wb.datasources[0] # Assume the first datasource in the file + # Assume the first datasource in the file + self.ds = self.wb.datasources[0] def test_datasource_fields_found_in_returns_fields(self): actual_values = self.ds.fields.used_by_sheet('Sheet 1') From 4d6ff518cb86b49902d35a746874d241e5576e96 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Wed, 27 Jul 2016 18:57:01 +0800 Subject: [PATCH 03/11] Make sure we are loading the expected filetype (#62) * Proper validation that Workbooks load 'twb(x)' and Datasources load 'tds(x)' files only * Small cleanup, move the try inside the with * Add some tests to make sure I don't break anything and cleanup a few small bits --- tableaudocumentapi/datasource.py | 2 +- tableaudocumentapi/workbook.py | 2 +- tableaudocumentapi/xfile.py | 32 ++++++++++++++++++++++---------- test/assets/TABLEAU_82_TWB.twb | 1 + test/bvt.py | 21 +++++++++++++++++++++ 5 files changed, 46 insertions(+), 12 deletions(-) create mode 100644 test/assets/TABLEAU_82_TWB.twb diff --git a/tableaudocumentapi/datasource.py b/tableaudocumentapi/datasource.py index 3f64145..09860b4 100644 --- a/tableaudocumentapi/datasource.py +++ b/tableaudocumentapi/datasource.py @@ -113,7 +113,7 @@ def __init__(self, dsxml, filename=None): def from_file(cls, filename): """Initialize datasource from file (.tds)""" - dsxml = xml_open(filename).getroot() + dsxml = xml_open(filename, cls.__name__.lower()).getroot() return cls(dsxml, filename) def save(self): diff --git a/tableaudocumentapi/workbook.py b/tableaudocumentapi/workbook.py index 28ddd03..1359356 100644 --- a/tableaudocumentapi/workbook.py +++ b/tableaudocumentapi/workbook.py @@ -32,7 +32,7 @@ def __init__(self, filename): self._filename = filename - self._workbookTree = xml_open(self._filename) + self._workbookTree = xml_open(self._filename, self.__class__.__name__.lower()) self._workbookRoot = self._workbookTree.getroot() # prepare our datasource objects diff --git a/tableaudocumentapi/xfile.py b/tableaudocumentapi/xfile.py index a0cd62e..8f9ffd1 100644 --- a/tableaudocumentapi/xfile.py +++ b/tableaudocumentapi/xfile.py @@ -17,15 +17,28 @@ class TableauVersionNotSupportedException(Exception): pass -def xml_open(filename): - # Determine if this is a twb or twbx and get the xml root +class TableauInvalidFileException(Exception): + pass + + +def xml_open(filename, expected_root=None): + if zipfile.is_zipfile(filename): tree = get_xml_from_archive(filename) else: tree = ET.parse(filename) - file_version = Version(tree.getroot().attrib.get('version', '0.0')) + + tree_root = tree.getroot() + + file_version = Version(tree_root.attrib.get('version', '0.0')) + if file_version < MIN_SUPPORTED_VERSION: raise TableauVersionNotSupportedException(file_version) + + if expected_root and (expected_root != tree_root.tag): + raise TableauInvalidFileException( + "'{}'' is not a valid '{}' file".format(filename, expected_root)) + return tree @@ -40,14 +53,13 @@ def temporary_directory(*args, **kwargs): def find_file_in_zip(zip_file): for filename in zip_file.namelist(): - try: - with zip_file.open(filename) as xml_candidate: - ET.parse(xml_candidate).getroot().tag in ( - 'workbook', 'datasource') + with zip_file.open(filename) as xml_candidate: + try: + ET.parse(xml_candidate) return filename - except ET.ParseError: - # That's not an XML file by gosh - pass + except ET.ParseError: + # That's not an XML file by gosh + pass def get_xml_from_archive(filename): diff --git a/test/assets/TABLEAU_82_TWB.twb b/test/assets/TABLEAU_82_TWB.twb new file mode 100644 index 0000000..2ab236d --- /dev/null +++ b/test/assets/TABLEAU_82_TWB.twb @@ -0,0 +1 @@ + diff --git a/test/bvt.py b/test/bvt.py index d26b91a..ce96afe 100644 --- a/test/bvt.py +++ b/test/bvt.py @@ -4,9 +4,12 @@ import xml.etree.ElementTree as ET from tableaudocumentapi import Workbook, Datasource, Connection, ConnectionParser +from tableaudocumentapi.xfile import TableauInvalidFileException, TableauVersionNotSupportedException TEST_DIR = os.path.dirname(__file__) +TABLEAU_82_TWB = os.path.join(TEST_DIR, 'assets', 'TABLEAU_82_TWB.twb') + TABLEAU_93_TWB = os.path.join(TEST_DIR, 'assets', 'TABLEAU_93_TWB.twb') TABLEAU_93_TDS = os.path.join(TEST_DIR, 'assets', 'TABLEAU_93_TDS.tds') @@ -289,10 +292,28 @@ def test_can_open_twbx_and_save_as_changes(self): class EmptyWorkbookWillLoad(unittest.TestCase): + def test_no_exceptions_thrown(self): wb = Workbook(EMPTY_WORKBOOK) self.assertIsNotNone(wb) +class LoadOnlyValidFileTypes(unittest.TestCase): + + def test_exception_when_workbook_given_tdsx(self): + with self.assertRaises(TableauInvalidFileException): + wb = Workbook(TABLEAU_10_TDSX) + + def test_exception_when_datasource_given_twbx(self): + with self.assertRaises(TableauInvalidFileException): + ds = Datasource.from_file(TABLEAU_10_TWBX) + + +class SupportedWorkbookVersions(unittest.TestCase): + + def test_82_workbook_throws_exception(self): + with self.assertRaises(TableauVersionNotSupportedException): + wb = Workbook(TABLEAU_82_TWB) + if __name__ == '__main__': unittest.main() From e0d1eb5af3aa294b21a7f2cc3bd1f03ab1030b71 Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Wed, 27 Jul 2016 09:47:28 -0700 Subject: [PATCH 04/11] address lack of coverage from #63 (#67) --- tableaudocumentapi/multilookup_dict.py | 24 +++++++++++++++--------- test/test_multidict.py | 9 +++++++++ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/tableaudocumentapi/multilookup_dict.py b/tableaudocumentapi/multilookup_dict.py index 64b742a..6677b07 100644 --- a/tableaudocumentapi/multilookup_dict.py +++ b/tableaudocumentapi/multilookup_dict.py @@ -39,15 +39,25 @@ def _populate_indexes(self): self._indexes['alias'] = _build_index('alias', self) self._indexes['caption'] = _build_index('caption', self) + def _get_real_key(self, key): + if key in self._indexes['alias']: + return self._indexes['alias'][key] + if key in self._indexes['caption']: + return self._indexes['caption'][key] + + return key + def __setitem__(self, key, value): + real_key = self._get_real_key(key) + alias = _resolve_value('alias', value) caption = _resolve_value('caption', value) if alias is not None: - self._indexes['alias'][alias] = key + self._indexes['alias'][alias] = real_key if caption is not None: - self._indexes['caption'][caption] = key + self._indexes['caption'][caption] = real_key - dict.__setitem__(self, key, value) + dict.__setitem__(self, real_key, value) def get(self, key, default_value=_no_default_value): try: @@ -58,9 +68,5 @@ def get(self, key, default_value=_no_default_value): raise def __getitem__(self, key): - if key in self._indexes['alias']: - key = self._indexes['alias'][key] - elif key in self._indexes['caption']: - key = self._indexes['caption'][key] - - return dict.__getitem__(self, key) + real_key = self._get_real_key(key) + return dict.__getitem__(self, real_key) diff --git a/test/test_multidict.py b/test/test_multidict.py index 0a78e9d..4544c49 100644 --- a/test/test_multidict.py +++ b/test/test_multidict.py @@ -61,3 +61,12 @@ def test_multilookupdict_get_returns_default_value(self): def test_multilookupdict_get_returns_value(self): actual = self.mld.get('baz') self.assertEqual(1, actual['value']) + + def test_multilookupdict_can_set_item(self): + before = self.mld['baz'] + self.mld['baz'] = 4 + self.assertEqual(4, self.mld['baz']) + + def test_multilookupdict_can_set_new_item(self): + self.mld['wakka'] = 1 + self.assertEqual(1, self.mld['wakka']) From 90d6a283849f9340ea37bfa98a746e3e47cd3b17 Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Tue, 9 Aug 2016 15:38:14 -0700 Subject: [PATCH 05/11] Improve test coverage (#70) * Removing code that should have been removed in a previous checkin * another small improvement * Adding coverage for one of the xfile edge cases * Adding a few additional tests * Updating based on code review feedback. Note: assertRaises does not take a message --- tableaudocumentapi/datasource.py | 2 +- tableaudocumentapi/field.py | 6 +++-- tableaudocumentapi/multilookup_dict.py | 8 +------ test/assets/BadZip.zip | Bin 0 -> 201 bytes test/bvt.py | 8 +++++++ test/test_field.py | 29 +++++++++++++++++++++++++ test/test_multidict.py | 8 +++++++ test/test_xfile.py | 19 ++++++++++++++++ 8 files changed, 70 insertions(+), 10 deletions(-) create mode 100644 test/assets/BadZip.zip create mode 100644 test/test_field.py create mode 100644 test/test_xfile.py diff --git a/tableaudocumentapi/datasource.py b/tableaudocumentapi/datasource.py index 09860b4..b762f62 100644 --- a/tableaudocumentapi/datasource.py +++ b/tableaudocumentapi/datasource.py @@ -19,7 +19,7 @@ # dropped, remove this and change the basestring references below to str try: basestring -except NameError: +except NameError: # pragma: no cover basestring = str ######## diff --git a/tableaudocumentapi/field.py b/tableaudocumentapi/field.py index 4af648f..711e772 100644 --- a/tableaudocumentapi/field.py +++ b/tableaudocumentapi/field.py @@ -42,8 +42,10 @@ def __init__(self, column_xml=None, metadata_xml=None): if column_xml is not None: self._initialize_from_column_xml(column_xml) - if metadata_xml is not None: - self.apply_metadata(metadata_xml) + # This isn't currently never called because of the way we get the data from the xml, + # but during the refactor, we might need it. This is commented out as a reminder + # if metadata_xml is not None: + # self.apply_metadata(metadata_xml) elif metadata_xml is not None: self._initialize_from_metadata_xml(metadata_xml) diff --git a/tableaudocumentapi/multilookup_dict.py b/tableaudocumentapi/multilookup_dict.py index 6677b07..732b7da 100644 --- a/tableaudocumentapi/multilookup_dict.py +++ b/tableaudocumentapi/multilookup_dict.py @@ -13,6 +13,7 @@ def _resolve_value(key, value): if retval is None: retval = getattr(value, key, None) except AttributeError: + # We should never hit this. retval = None return retval @@ -50,13 +51,6 @@ def _get_real_key(self, key): def __setitem__(self, key, value): real_key = self._get_real_key(key) - alias = _resolve_value('alias', value) - caption = _resolve_value('caption', value) - if alias is not None: - self._indexes['alias'][alias] = real_key - if caption is not None: - self._indexes['caption'][caption] = real_key - dict.__setitem__(self, real_key, value) def get(self, key, default_value=_no_default_value): diff --git a/test/assets/BadZip.zip b/test/assets/BadZip.zip new file mode 100644 index 0000000000000000000000000000000000000000..9eca08f821d6dd60285c038233f430d94072a67f GIT binary patch literal 201 zcmWIWW@h1H00F-oPEXFcu}kHFY!K#RkYR92Oo{OI(W}VK2@T<7U=Frh6%N9s72FJr zEa59BhqENIZ)WyX&sE6FFHuO$Qvj+~NXyJg)lo>QR7lUy$*I)i3h-uRl4HhYi3G@Q i21X!W(g Date: Tue, 16 Aug 2016 06:05:07 +1000 Subject: [PATCH 06/11] Update README.md (#71) CLANotRequired --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a6534d2..588fb14 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Features include: - Database Name - Getting Field information from data sources and workbooks - Get all fields in a data source - - Get all feilds in use by certain sheets in a workbook + - Get all fields in use by certain sheets in a workbook We don't yet support creating files from scratch, adding extracts into workbooks or data sources, or updating field information @@ -45,7 +45,7 @@ Download the `.zip` file that contains the SDK. Unzip the file and then run the pip install -e ``` -#### Installing the Development Version From Git +#### Installing the Development Version from Git *Only do this if you know you want the development version, no guarantee that we won't break APIs during development* @@ -74,7 +74,7 @@ sourceWB.datasources[0].connections[0].username = "benl" sourceWB.save() ``` -With Data Integration in Tableau 10, a data source can have multiple connections. To access the connections simply index them like you would datasources +With Data Integration in Tableau 10, a data source can have multiple connections. To access the connections simply index them like you would datasources. ```python from tableaudocumentapi import Workbook @@ -104,6 +104,6 @@ sourceWB.save() -###Examples +###[Examples](Examples) -The downloadable package contains several example scripts that show more detailed usage of the Document API +The downloadable package contains several example scripts that show more detailed usage of the Document API. From 8b8f351adf1a0037e5c399249dd8470851664611 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Mon, 15 Aug 2016 16:08:06 -0700 Subject: [PATCH 07/11] Create New Connections per #68 (#69) Enable a prototypical API for creating new connections and datasources from scratch. This uses ET to do XML manipulation, and is hard coded to "10.0" style integrated connections. This will go away with the new editor/physical/logical model work underway, but it fixes a bug and will let us play around for now. --- tableaudocumentapi/connection.py | 14 +++++++++- tableaudocumentapi/datasource.py | 47 ++++++++++++++++++++++++++++++++ tableaudocumentapi/xfile.py | 4 +++ test/bvt.py | 19 +++++++++++++ 4 files changed, 83 insertions(+), 1 deletion(-) diff --git a/tableaudocumentapi/connection.py b/tableaudocumentapi/connection.py index ab4dcbb..8e9eb58 100644 --- a/tableaudocumentapi/connection.py +++ b/tableaudocumentapi/connection.py @@ -3,6 +3,7 @@ # Connection - A class for writing connections to Tableau files # ############################################################################### +import xml.etree.ElementTree as ET from tableaudocumentapi.dbclass import is_valid_dbclass @@ -33,6 +34,17 @@ def __init__(self, connxml): def __repr__(self): return "''".format(self._server, self._dbname, hex(id(self))) + @classmethod + def from_attributes(cls, server, dbname, username, dbclass, authentication=''): + root = ET.Element('connection', authentication=authentication) + xml = cls(root) + xml.server = server + xml.dbname = dbname + xml.username = username + xml.dbclass = dbclass + + return xml + ########### # dbname ########### @@ -120,4 +132,4 @@ def dbclass(self, value): raise AttributeError("'{}' is not a valid database type".format(value)) self._class = value - self._connectionXML.set('dbclass', value) + self._connectionXML.set('class', value) diff --git a/tableaudocumentapi/datasource.py b/tableaudocumentapi/datasource.py index b762f62..dd3d5c5 100644 --- a/tableaudocumentapi/datasource.py +++ b/tableaudocumentapi/datasource.py @@ -5,8 +5,10 @@ ############################################################################### import collections import itertools +import random import xml.etree.ElementTree as ET import xml.sax.saxutils as sax +from uuid import uuid4 from tableaudocumentapi import Connection, xfile from tableaudocumentapi import Field @@ -38,6 +40,7 @@ def _is_used_by_worksheet(names, field): class FieldDictionary(MultiLookupDict): + def used_by_sheet(self, name): # If we pass in a string, no need to get complicated, just check to see if name is in # the field's list of worksheets @@ -63,7 +66,36 @@ def _column_object_from_metadata_xml(metadata_xml): return _ColumnObjectReturnTuple(field_object.id, field_object) +def base36encode(number): + """Converts an integer into a base36 string.""" + + ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz" + + base36 = '' + sign = '' + + if number < 0: + sign = '-' + number = -number + + if 0 <= number < len(ALPHABET): + return sign + ALPHABET[number] + + while number != 0: + number, i = divmod(number, len(ALPHABET)) + base36 = ALPHABET[i] + base36 + + return sign + base36 + + +def make_unique_name(dbclass): + rand_part = base36encode(uuid4().int) + name = dbclass + '.' + rand_part + return name + + class ConnectionParser(object): + def __init__(self, datasource_xml, version): self._dsxml = datasource_xml self._dsversion = version @@ -116,6 +148,20 @@ def from_file(cls, filename): dsxml = xml_open(filename, cls.__name__.lower()).getroot() return cls(dsxml, filename) + @classmethod + def from_connections(cls, caption, connections): + root = ET.Element('datasource', caption=caption, version='10.0', inline='true') + outer_connection = ET.SubElement(root, 'connection') + outer_connection.set('class', 'federated') + named_conns = ET.SubElement(outer_connection, 'named-connections') + for conn in connections: + nc = ET.SubElement(named_conns, + 'named-connection', + name=make_unique_name(conn.dbclass), + caption=conn.server) + nc.append(conn._connectionXML) + return cls(root) + def save(self): """ Call finalization code and save file. @@ -143,6 +189,7 @@ def save_as(self, new_filename): Nothing. """ + xfile._save_file(self._filename, self._datasourceTree, new_filename) ########### diff --git a/tableaudocumentapi/xfile.py b/tableaudocumentapi/xfile.py index 8f9ffd1..66e5aac 100644 --- a/tableaudocumentapi/xfile.py +++ b/tableaudocumentapi/xfile.py @@ -104,6 +104,10 @@ def save_into_archive(xml_tree, filename, new_filename=None): def _save_file(container_file, xml_tree, new_filename=None): + + if container_file is None: + container_file = new_filename + if zipfile.is_zipfile(container_file): save_into_archive(xml_tree, container_file, new_filename) else: diff --git a/test/bvt.py b/test/bvt.py index e8870d0..4ea3d11 100644 --- a/test/bvt.py +++ b/test/bvt.py @@ -74,6 +74,25 @@ def test_bad_dbclass_rasies_attribute_error(self): with self.assertRaises(AttributeError): conn.dbclass = 'NotReal' + def test_can_create_connection_from_scratch(self): + conn = Connection.from_attributes( + server='a', dbname='b', username='c', dbclass='mysql', authentication='d') + self.assertEqual(conn.server, 'a') + self.assertEqual(conn.dbname, 'b') + self.assertEqual(conn.username, 'c') + self.assertEqual(conn.dbclass, 'mysql') + self.assertEqual(conn.authentication, 'd') + + def test_can_create_datasource_from_connections(self): + conn1 = Connection.from_attributes( + server='a', dbname='b', username='c', dbclass='mysql', authentication='d') + conn2 = Connection.from_attributes( + server='1', dbname='2', username='3', dbclass='mysql', authentication='7') + ds = Datasource.from_connections('test', connections=[conn1, conn2]) + + self.assertEqual(ds.connections[0].server, 'a') + self.assertEqual(ds.connections[1].server, '1') + class DatasourceModelTests(unittest.TestCase): From 202aceaf12870e79c4ead6e100ccbd90e8b5716c Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Tue, 23 Aug 2016 09:20:40 -0700 Subject: [PATCH 08/11] #72 Add description to the field object (#73) * Add description to the field object Pull in the description if it exists on a `` tag. This change returns the entire `` tag including all subtags. e.g.: ``` Total number of people in a country ``` * improving the test code to use 'in' --- Examples/GetFields/show_fields.py | 2 ++ tableaudocumentapi/field.py | 16 ++++++++++++++++ test/assets/datasource_test.tds | 9 ++++++++- test/test_datasource.py | 5 +++++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/Examples/GetFields/show_fields.py b/Examples/GetFields/show_fields.py index ee45f87..40d87fe 100644 --- a/Examples/GetFields/show_fields.py +++ b/Examples/GetFields/show_fields.py @@ -23,6 +23,8 @@ if field.default_aggregation: print(' the default aggregation is {}'.format(field.default_aggregation)) blank_line = True + if field.description: + print(' the description is {}'.format(field.description)) if blank_line: print('') diff --git a/tableaudocumentapi/field.py b/tableaudocumentapi/field.py index 711e772..63cc72c 100644 --- a/tableaudocumentapi/field.py +++ b/tableaudocumentapi/field.py @@ -1,4 +1,6 @@ import functools +import xml.etree.ElementTree as ET + _ATTRIBUTES = [ 'id', # Name of the field as specified in the file, usually surrounded by [ ] @@ -8,6 +10,7 @@ 'type', # three possible values: quantitative, ordinal, or nominal 'alias', # Name of the field as displayed in Tableau if the default name isn't wanted 'calculation', # If this field is a calculated field, this will be the formula + 'description', # If this field has a description, this will be the description (including formatting tags) ] _METADATA_ATTRIBUTES = [ @@ -164,6 +167,11 @@ def default_aggregation(self): """ The default type of aggregation on the field (e.g Sum, Avg)""" return self._aggregation + @property + def description(self): + """ The contents of the tag on a field """ + return self._description + @property def worksheets(self): return list(self._worksheets) @@ -184,3 +192,11 @@ def _read_calculation(xmldata): return None return calc.attrib.get('formula', None) + + @staticmethod + def _read_description(xmldata): + description = xmldata.find('.//desc') + if description is None: + return None + + return u'{}'.format(ET.tostring(description, encoding='utf-8')) # This is necessary for py3 support diff --git a/test/assets/datasource_test.tds b/test/assets/datasource_test.tds index a1e78a8..bfab77b 100644 --- a/test/assets/datasource_test.tds +++ b/test/assets/datasource_test.tds @@ -75,7 +75,14 @@ - + + + + A thing + Something will go here too, in a muted gray + + + diff --git a/test/test_datasource.py b/test/test_datasource.py index 6ea03ea..66b3f79 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -58,6 +58,11 @@ def test_datasource_field_datatype(self): def test_datasource_field_role(self): self.assertEqual(self.ds.fields['[x]'].role, 'measure') + def test_datasource_field_description(self): + actual = self.ds.fields['[a]'].description + self.assertIsNotNone(actual) + self.assertTrue(u'muted gray' in actual) + class DataSourceFieldsTWB(unittest.TestCase): From 91260499984240188b1eff8630a5f1fca6d93833 Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Mon, 29 Aug 2016 13:14:48 -0700 Subject: [PATCH 09/11] Cleanup samples (#74) --- .travis.yml | 6 +++--- Examples/GetFields/World.tds | 1 - .../list-tds-info/list_tds_info.py | 2 +- .../World.tds => samples/list-tds-info/world.tds | 0 .../replicate-workbook}/databases.csv | 0 .../replicate-workbook/replicate_workbook.py | 2 +- .../replicate-workbook/sample-superstore.twb | 0 {Examples/GetFields => samples/show-fields}/show_fields.py | 2 +- samples/show-fields/world.tds | 1 + 9 files changed, 7 insertions(+), 7 deletions(-) delete mode 120000 Examples/GetFields/World.tds rename Examples/List TDS Info/listTDSInfo.py => samples/list-tds-info/list_tds_info.py (94%) rename Examples/List TDS Info/World.tds => samples/list-tds-info/world.tds (100%) rename {Examples/Replicate Workbook => samples/replicate-workbook}/databases.csv (100%) rename Examples/Replicate Workbook/replicateWorkbook.py => samples/replicate-workbook/replicate_workbook.py (96%) rename Examples/Replicate Workbook/Sample - Superstore.twb => samples/replicate-workbook/sample-superstore.twb (100%) rename {Examples/GetFields => samples/show-fields}/show_fields.py (96%) create mode 120000 samples/show-fields/world.tds diff --git a/.travis.yml b/.travis.yml index 75674b6..e2a9073 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,7 +16,7 @@ script: # pep8 - pep8 . # Examples - - (cd "Examples/Replicate Workbook" && python replicateWorkbook.py) - - (cd "Examples/List TDS Info" && python listTDSInfo.py) - - (cd "Examples/GetFields" && python show_fields.py) + - (cd "samples/replicate-workbook" && python replicate_workbook.py) + - (cd "samples/list-tds-info" && python list_tds_info.py) + - (cd "samples/show-fields" && python show_fields.py) diff --git a/Examples/GetFields/World.tds b/Examples/GetFields/World.tds deleted file mode 120000 index 397f696..0000000 --- a/Examples/GetFields/World.tds +++ /dev/null @@ -1 +0,0 @@ -../List TDS Info/World.tds \ No newline at end of file diff --git a/Examples/List TDS Info/listTDSInfo.py b/samples/list-tds-info/list_tds_info.py similarity index 94% rename from Examples/List TDS Info/listTDSInfo.py rename to samples/list-tds-info/list_tds_info.py index 0c73d3a..129f85c 100644 --- a/Examples/List TDS Info/listTDSInfo.py +++ b/samples/list-tds-info/list_tds_info.py @@ -6,7 +6,7 @@ ############################################################ # Step 2) Open the .tds we want to replicate ############################################################ -sourceTDS = Datasource.from_file('World.tds') +sourceTDS = Datasource.from_file('world.tds') ############################################################ # Step 3) List out info from the TDS diff --git a/Examples/List TDS Info/World.tds b/samples/list-tds-info/world.tds similarity index 100% rename from Examples/List TDS Info/World.tds rename to samples/list-tds-info/world.tds diff --git a/Examples/Replicate Workbook/databases.csv b/samples/replicate-workbook/databases.csv similarity index 100% rename from Examples/Replicate Workbook/databases.csv rename to samples/replicate-workbook/databases.csv diff --git a/Examples/Replicate Workbook/replicateWorkbook.py b/samples/replicate-workbook/replicate_workbook.py similarity index 96% rename from Examples/Replicate Workbook/replicateWorkbook.py rename to samples/replicate-workbook/replicate_workbook.py index 4b555e6..65281cb 100644 --- a/Examples/Replicate Workbook/replicateWorkbook.py +++ b/samples/replicate-workbook/replicate_workbook.py @@ -8,7 +8,7 @@ ############################################################ # Step 2) Open the .twb we want to replicate ############################################################ -sourceWB = Workbook('Sample - Superstore.twb') +sourceWB = Workbook('sample-superstore.twb') ############################################################ # Step 3) Use a database list (in CSV), loop thru and diff --git a/Examples/Replicate Workbook/Sample - Superstore.twb b/samples/replicate-workbook/sample-superstore.twb similarity index 100% rename from Examples/Replicate Workbook/Sample - Superstore.twb rename to samples/replicate-workbook/sample-superstore.twb diff --git a/Examples/GetFields/show_fields.py b/samples/show-fields/show_fields.py similarity index 96% rename from Examples/GetFields/show_fields.py rename to samples/show-fields/show_fields.py index 40d87fe..84cdd44 100644 --- a/Examples/GetFields/show_fields.py +++ b/samples/show-fields/show_fields.py @@ -6,7 +6,7 @@ ############################################################ # Step 2) Open the .tds we want to inspect ############################################################ -sourceTDS = Datasource.from_file('World.tds') +sourceTDS = Datasource.from_file('world.tds') ############################################################ # Step 3) Print out all of the fields and what type they are diff --git a/samples/show-fields/world.tds b/samples/show-fields/world.tds new file mode 120000 index 0000000..5e73bb3 --- /dev/null +++ b/samples/show-fields/world.tds @@ -0,0 +1 @@ +../list-tds-info/world.tds \ No newline at end of file From ff372f480ec12fa4b5db032a0d495c914799ac3a Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Wed, 31 Aug 2016 09:02:31 -0700 Subject: [PATCH 10/11] Prepping for 0.3 release (#75) * Prepping for 0.3 release * Cleanup samples (#74) --- CHANGELOG.md | 7 +++++++ setup.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33b164c..a62895f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 0.3 (31 August 2016) + +* Added basic connection class retargeting (#65) +* Added ability to create a new connection (#69) +* Improved Test Coverage (#62, #67) +* Added description to the field object (#73) + ## 0.2 (22 July 2016) * Added support for loading twbx and tdsx files (#43, #44) diff --git a/setup.py b/setup.py index e047b34..27d6a73 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name='tableaudocumentapi', - version='0.2', + version='0.3', author='Tableau Software', author_email='github@tableau.com', url='https://github.com/tableau/document-api-python', From 70781773059fda4b1592cbe4f4b85e8887f836af Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Wed, 31 Aug 2016 09:04:15 -0700 Subject: [PATCH 11/11] reordering changelog to move features about test improvements (#77) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a62895f..7ba40cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,8 @@ * Added basic connection class retargeting (#65) * Added ability to create a new connection (#69) -* Improved Test Coverage (#62, #67) * Added description to the field object (#73) +* Improved Test Coverage (#62, #67) ## 0.2 (22 July 2016) pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy