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/CHANGELOG.md b/CHANGELOG.md index 33b164c..7ba40cd 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) +* Added description to the field object (#73) +* Improved Test Coverage (#62, #67) + ## 0.2 (22 July 2016) * Added support for loading twbx and tdsx files (#43, #44) 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/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. 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 90% rename from Examples/GetFields/show_fields.py rename to samples/show-fields/show_fields.py index ee45f87..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 @@ -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/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 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', diff --git a/tableaudocumentapi/connection.py b/tableaudocumentapi/connection.py index 3e64c1b..8e9eb58 100644 --- a/tableaudocumentapi/connection.py +++ b/tableaudocumentapi/connection.py @@ -3,6 +3,8 @@ # Connection - A class for writing connections to Tableau files # ############################################################################### +import xml.etree.ElementTree as ET +from tableaudocumentapi.dbclass import is_valid_dbclass class Connection(object): @@ -32,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 ########### @@ -111,3 +124,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('class', value) diff --git a/tableaudocumentapi/datasource.py b/tableaudocumentapi/datasource.py index 3f64145..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 @@ -19,7 +21,7 @@ # dropped, remove this and change the basestring references below to str try: basestring -except NameError: +except NameError: # pragma: no cover basestring = str ######## @@ -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 @@ -113,9 +145,23 @@ 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) + @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/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/tableaudocumentapi/field.py b/tableaudocumentapi/field.py index 4af648f..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 = [ @@ -42,8 +45,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) @@ -162,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) @@ -182,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/tableaudocumentapi/multilookup_dict.py b/tableaudocumentapi/multilookup_dict.py index 64b742a..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 @@ -39,15 +40,18 @@ 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): - alias = _resolve_value('alias', value) - caption = _resolve_value('caption', value) - if alias is not None: - self._indexes['alias'][alias] = key - if caption is not None: - self._indexes['caption'][caption] = key + real_key = self._get_real_key(key) - dict.__setitem__(self, key, value) + dict.__setitem__(self, real_key, value) def get(self, key, default_value=_no_default_value): try: @@ -58,9 +62,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/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..66e5aac 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): @@ -92,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/assets/BadZip.zip b/test/assets/BadZip.zip new file mode 100644 index 0000000..9eca08f Binary files /dev/null and b/test/assets/BadZip.zip differ 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/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/bvt.py b/test/bvt.py index 6a7cdf8..4ea3d11 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') @@ -64,6 +67,32 @@ 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' + + 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): @@ -191,6 +220,14 @@ def test_can_extract_datasource(self): self.assertEqual(wb.datasources[0].name, 'sqlserver.17u3bqc16tjtxn14e2hxh19tyvpo') + def test_can_get_worksheets(self): + wb = Workbook(self.workbook_file.name) + self.assertIsNotNone(wb.worksheets) + + def test_has_filename(self): + wb = Workbook(self.workbook_file.name) + self.assertEqual(wb.filename, self.workbook_file.name) + def test_can_update_datasource_connection_and_save(self): original_wb = Workbook(self.workbook_file.name) original_wb.datasources[0].connections[0].dbname = 'newdb.test.tsi.lan' @@ -282,10 +319,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() diff --git a/test/test_datasource.py b/test/test_datasource.py index bf51746..66b3f79 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,24 @@ 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') + + 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): + 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 +77,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') diff --git a/test/test_field.py b/test/test_field.py new file mode 100644 index 0000000..7cbe885 --- /dev/null +++ b/test/test_field.py @@ -0,0 +1,29 @@ +import unittest +import os.path + +from tableaudocumentapi import Datasource, Field +from tableaudocumentapi.field import _find_metadata_record + +TEST_ASSET_DIR = os.path.join( + os.path.dirname(__file__), + 'assets' +) +TEST_TDS_FILE = os.path.join( + TEST_ASSET_DIR, + 'datasource_test.tds' +) + + +class FieldsUnitTest(unittest.TestCase): + def test_field_throws_if_no_data_passed_in(self): + with self.assertRaises(AttributeError): + Field() + + +class FindMetaDataRecordEdgeTest(unittest.TestCase): + class MockXmlWithNoFind(object): + def find(self, *args, **kwargs): + return None + + def test_find_metadata_record_returns_none(self): + self.assertIsNone(_find_metadata_record(self.MockXmlWithNoFind(), 'foo')) diff --git a/test/test_multidict.py b/test/test_multidict.py index 0a78e9d..21ecdc6 100644 --- a/test/test_multidict.py +++ b/test/test_multidict.py @@ -22,6 +22,10 @@ def setUp(self): } }) + def test_multilookupdict_can_be_empty(self): + mld = MultiLookupDict() + self.assertIsNotNone(mld) + def test_multilookupdict_name_only(self): actual = self.mld['[baz]'] self.assertEqual(3, actual['value']) @@ -61,3 +65,16 @@ 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']) + + def test_multilookupdict_can_set_with_alias(self): + self.mld['bar'] = 2 + self.assertEqual(2, self.mld['[foo]']) diff --git a/test/test_xfile.py b/test/test_xfile.py new file mode 100644 index 0000000..6cbe67f --- /dev/null +++ b/test/test_xfile.py @@ -0,0 +1,19 @@ +import os.path +import unittest +import zipfile +from tableaudocumentapi.xfile import find_file_in_zip + +TEST_ASSET_DIR = os.path.join( + os.path.dirname(__file__), + 'assets' +) +BAD_ZIP_FILE = os.path.join( + TEST_ASSET_DIR, + 'BadZip.zip' +) + + +class XFileEdgeTests(unittest.TestCase): + def test_find_file_in_zip_no_xml_file(self): + badzip = zipfile.ZipFile(BAD_ZIP_FILE) + self.assertIsNone(find_file_in_zip(badzip))