From 8992861a7bfd25102b98a5f7f42c58bde54b716e Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 6 Nov 2016 14:41:50 +0100 Subject: [PATCH 1/4] Added the ability to create and modify fields, including aliases and calculated fields --- tableaudocumentapi/datasource.py | 86 +++++++++++- tableaudocumentapi/field.py | 175 ++++++++++++++++++++++++- test/assets/.gitignore | 1 + test/assets/field_change_test.tds | 103 +++++++++++++++ test/test_field_change.py | 208 ++++++++++++++++++++++++++++++ 5 files changed, 568 insertions(+), 5 deletions(-) create mode 100644 test/assets/.gitignore create mode 100644 test/assets/field_change_test.tds create mode 100644 test/test_field_change.py diff --git a/tableaudocumentapi/datasource.py b/tableaudocumentapi/datasource.py index a34cba5..1e7f64f 100644 --- a/tableaudocumentapi/datasource.py +++ b/tableaudocumentapi/datasource.py @@ -240,9 +240,12 @@ def clear_repository_location(self): @property def fields(self): if not self._fields: - self._fields = self._get_all_fields() + self._refresh_fields() return self._fields + def _refresh_fields(self): + self._fields = self._get_all_fields() + def _get_all_fields(self): column_field_objects = self._get_column_objects() existing_column_fields = [x.id for x in column_field_objects] @@ -258,3 +261,84 @@ def _get_metadata_objects(self): def _get_column_objects(self): return [_column_object_from_column_xml(self._datasourceTree, xml) for xml in self._datasourceTree.findall('.//column')] + + def add_field(self, name, datatype, role, type, caption): + """ Adds a base field object with the given values. + + Args: + name: Name of the new Field. String. + datatype: Datatype of the new field. String. + role: Role of the new field. String. + type: Type of the new field. String. + caption: Caption of the new field. String. + + Returns: + The new field that was created. Field. + """ + # TODO: A better approach would be to create an empty column and then + # use the input validation from its "Field"-object-representation to set values. + # However, creating an empty column causes errors :( + + # If no caption is specified, create one with the same format Tableau does + if not caption: + caption = name.replace('[', '').replace(']', '').title() + + # Create the elements + column = ET.Element('column') + column.set('caption', caption) + column.set('datatype', datatype) + column.set('role', role) + column.set('type', type) + column.set('name', name) + + self._datasourceTree.getroot().append(column) + + # Refresh fields to reflect changes and return the Field object + self._refresh_fields() + return self.fields[name] + + def remove_field(self, field): + """ Remove a given field + + Args: + field: The field to remove. ET.Element + + Returns: + None + """ + if not field or not isinstance(field, Field): + raise ValueError("Need to supply a field to remove element") + + self._datasourceTree.getroot().remove(field.xml) + self._refresh_fields() + + ########### + # Calculations + ########### + @property + def calculations(self): + """ Returns all calculated fields. + """ + # TODO: There is a default [Number of Records] calculation. + # Should this be excluded so users can't meddle with it? + return {k: v for k, v in self.fields.items() if v.calculation is not None} + + def add_calculation(self, caption, formula, datatype, role, type): + """ Adds a calculated field with the given values. + + Args: + caption: Caption of the new calculation. String. + formula: Formula of the new calculation. String. + datatype: Datatype of the new calculation. String. + role: Role of the new calculation. String. + type: Type of the new calculation. String. + + Returns: + The new calculated field that was created. Field. + """ + # Dynamically create the name of the field + name = '[Calculation_{}]'.format(str(uuid4().int)[:18]) + field = self.add_field(name, datatype, role, type, caption) + field.calculation = formula + + return field diff --git a/tableaudocumentapi/field.py b/tableaudocumentapi/field.py index 65ce78d..e924e72 100644 --- a/tableaudocumentapi/field.py +++ b/tableaudocumentapi/field.py @@ -1,6 +1,6 @@ import functools import xml.etree.ElementTree as ET - +from functools import wraps _ATTRIBUTES = [ 'id', # Name of the field as specified in the file, usually surrounded by [ ] @@ -24,6 +24,21 @@ ] +def argument_is_one_of(*allowed_values): + def property_type_decorator(func): + @wraps(func) + def wrapper(self, value): + if value not in allowed_values: + error = "Invalid argument: {0}. {1} must be one of {2}." + msg = error.format(value, func.__name__, allowed_values) + raise ValueError(error) + return func(self, value) + + return wrapper + + return property_type_decorator + + def _find_metadata_record(record, attrib): element = record.find('.//{}'.format(attrib)) if element is None: @@ -43,14 +58,18 @@ def __init__(self, column_xml=None, metadata_xml=None): setattr(self, '_{}'.format(attrib), None) self._worksheets = set() + self._xml = None + if column_xml is not None: self._initialize_from_column_xml(column_xml) + self._xml = column_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._xml = metadata_xml self._initialize_from_metadata_xml(metadata_xml) else: @@ -116,52 +135,200 @@ def id(self): """ Name of the field as specified in the file, usually surrounded by [ ] """ return self._id + @property + def xml(self): + """ XML representation of the field. """ + return self._xml + + ######################################## + # Attribute getters and setters + ######################################## + @property def caption(self): """ Name of the field as displayed in Tableau unless an aliases is defined """ return self._caption + @caption.setter + def caption(self, caption): + """ Set the caption of a field + + Args: + caption: New caption. String. + + Returns: + Nothing. + """ + self._caption = caption + self._xml.set('caption', caption) + @property def alias(self): """ Name of the field as displayed in Tableau if the default name isn't wanted """ return self._alias + @alias.setter + def alias(self, alias): + """ Set the alias of a field + + Args: + alias: New alias. String. + + Returns: + Nothing. + """ + self._alias = alias + self._xml.set('alias', alias) + @property def datatype(self): """ Type of the field within Tableau (string, integer, etc) """ return self._datatype + @datatype.setter + @argument_is_one_of('string', 'integer', 'date', 'boolean') + def datatype(self, datatype): + """ Set the datatype of a field + + Args: + datatype: New datatype. String. + + Returns: + Nothing. + """ + self._datatype = datatype + self._xml.set('datatype', datatype) + @property def role(self): """ Dimension or Measure """ return self._role + @role.setter + @argument_is_one_of('dimension', 'measure') + def role(self, role): + """ Set the role of a field + + Args: + role: New role. String. + + Returns: + Nothing. + """ + self._role = role + self._xml.set('role', role) + + @property + def type(self): + """ Dimension or Measure """ + return self._type + + @type.setter + @argument_is_one_of('quantitative', 'ordinal', 'nominal') + def type(self, type): + """ Set the type of a field + + Args: + type: New type. String. + + Returns: + Nothing. + """ + self._type = type + self._xml.set('type', type) + + ######################################## + # Aliases getter and setter + # Those are NOT the 'alias' field of the column, + # but instead the key-value aliases in its child elements + ######################################## + + def add_alias(self, key, value): + """ Add an alias for a given display value. + + Args: + key: The data value to map. Example: "1". String. + value: The display value for the key. Example: "True". String. + Returns: + Nothing. + """ + + # determine whether there already is an aliases-tag + aliases = self._xml.find('aliases') + # and create it if there isn't + if not aliases: + aliases = ET.Element('aliases') + self._xml.append(aliases) + + # find out if an alias with this key already exists and use it + existing_alias = [tag for tag in aliases.findall('alias') if tag.get('key') == key] + # if not, create a new ET.Element + alias = existing_alias[0] if existing_alias else ET.Element('alias') + + alias.set('key', key) + alias.set('value', value) + if not existing_alias: + aliases.append(alias) + + @property + def aliases(self): + """ Returns all aliases that are registered under this field. + + Returns: + Key-value mappings of all registered aliases. Dict. + """ + aliases_tag = self._xml.find('aliases') or [] + return {a.get('key', 'None'): a.get('value', 'None') for a in list(aliases_tag)} + + ######################################## + # Attribute getters + ######################################## + @property def is_quantitative(self): """ A dependent value, usually a measure of something e.g. Profit, Gross Sales """ - return self._type == 'quantitative' + return self.type == 'quantitative' @property def is_ordinal(self): """ Is this field a categorical field that has a specific order e.g. How do you feel? 1 - awful, 2 - ok, 3 - fantastic """ - return self._type == 'ordinal' + return self.type == 'ordinal' @property def is_nominal(self): """ Is this field a categorical field that does not have a specific order e.g. What color is your hair? """ - return self._type == 'nominal' + return self.type == 'nominal' @property def calculation(self): """ If this field is a calculated field, this will be the formula """ return self._calculation + @calculation.setter + def calculation(self, new_calculation): + """ Set the calculation of a calculated field. + + Args: + new_calculation: The new calculation/formula of the field. String. + """ + if self.calculation is None: + calculation = ET.Element('calculation') + calculation.set('class', 'tableau') + calculation.set('formula', new_calculation) + # Append the elements to the respective structure + self._xml.append(calculation) + + else: + self._xml.find('calculation').set('formula', new_calculation) + + self._calculation = new_calculation + @property def default_aggregation(self): """ The default type of aggregation on the field (e.g Sum, Avg)""" diff --git a/test/assets/.gitignore b/test/assets/.gitignore new file mode 100644 index 0000000..6aa8fd1 --- /dev/null +++ b/test/assets/.gitignore @@ -0,0 +1 @@ +field_change_test_output.tds diff --git a/test/assets/field_change_test.tds b/test/assets/field_change_test.tds new file mode 100644 index 0000000..dba389c --- /dev/null +++ b/test/assets/field_change_test.tds @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + name + 130 + [name] + [my_data] + name + 1 + string + Count + 8190 + true + true + + + "SQL_WLONGVARCHAR" + "SQL_C_WCHAR" + + + + typ + 130 + [typ] + [my_data] + typ + 2 + string + Count + 8190 + true + true + + + "SQL_WLONGVARCHAR" + "SQL_C_WCHAR" + + + + amount + 3 + [amount] + [my_data] + amount + 3 + integer + Sum + 10 + true + + "SQL_INTEGER" + "SQL_C_SLONG" + + + + price + 5 + [price] + [my_data] + price + 4 + real + Sum + 17 + true + + "SQL_FLOAT" + "SQL_C_DOUBLE" + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/test_field_change.py b/test/test_field_change.py new file mode 100644 index 0000000..6acd7b7 --- /dev/null +++ b/test/test_field_change.py @@ -0,0 +1,208 @@ +import unittest +import os.path + +from tableaudocumentapi import Datasource +import xml.etree.ElementTree as ET + + +TEST_ASSET_DIR = os.path.join( + os.path.dirname(__file__), + 'assets' +) +TEST_TDS_FILE = os.path.join( + TEST_ASSET_DIR, + 'field_change_test.tds' +) +TEST_TDS_FILE_OUTPUT = os.path.join( + TEST_ASSET_DIR, + 'field_change_test_output.tds' +) + +MESSAGES = { + 'test_change_values1': 'Value has not changed when altering values for {}.', + 'test_change_values2': 'XML-Structure has not changed when altering values for {}.', + 'test_change_valuesFail1': 'Value has changed when submitting the wrong value for {}.', + 'test_change_valuesFail2': 'XML-Structure has changed when submitting the wrong value for {}.', + 'test_change_aliases1': 'XML-Structure has not changed when altering aliases for {}.', + 'test_change_aliases2': 'Values have not changed when altering aliases for {}.' + +} + +NEW_VALUES = { + 'caption': 'testcaption', + 'alias': 'testalias', + 'datatype': 'boolean', + 'role': 'measure', + 'type': 'ordinal' +} + +WRONG_VALUES = { + 'datatype': 'boolani', + 'role': 'messhure', + 'type': 'gordinol' +} + +ALIASES = { + 'one': 'two', + 'three': 'four', + 'five': 'six' +} + + +class TestFieldChange(unittest.TestCase): + + def setUp(self): + self.tds = Datasource.from_file(TEST_TDS_FILE) + + def current_hash(self): + """ Return a hash of the current state of the XML. + + Allows us to easily identify whether the underlying XML-structure + of a TDS-file has actually changed. Avoids false positives if, + for example, a fields value has changed but the XML hasn't. + """ + return hash(ET.tostring(self.tds._datasourceTree.getroot())) + + def test_change_values(self): + """ Test if the value changes of a field are reflected in the object and in the underlying XML structure. + """ + field_to_test = "[amount]" + state = self.current_hash() + # change all fields + for key, value in NEW_VALUES.items(): + setattr(self.tds.fields[field_to_test], key, value) + # the new value must be reflected in the object + self.assertEqual( + getattr(self.tds.fields[field_to_test], key), + value, + msg=MESSAGES['test_change_values1'].format(key) + ) + # the new value must be reflected in the xml + new_state = self.current_hash() + self.assertNotEqual( + state, + new_state, + msg=MESSAGES['test_change_values2'].format(key) + ) + state = new_state + + def test_change_values_fail(self): + """ Test if the value changes of a field are rejected if the wrong arguments are passed. + """ + field_to_test = "[amount]" + state = self.current_hash() + # change all fields + for key, value in WRONG_VALUES.items(): + + with self.assertRaises(ValueError): + # this must fail + setattr(self.tds.fields[field_to_test], key, value) + + # the new value must NOT be reflected in the object + self.assertNotEqual( + getattr(self.tds.fields[field_to_test], key), + value, + msg=MESSAGES['test_change_valuesFail1'].format(key) + ) + + # the new value must NOT be reflected in the xml + new_state = self.current_hash() + self.assertEqual( + state, + new_state, + msg=MESSAGES['test_change_valuesFail2'].format(key) + ) + state = new_state + + def test_remove_field(self): + """ Test if a Field can be removed. + """ + field_to_test = "[amount]" + state = self.current_hash() + # change all fields + field = self.tds.fields["[amount]"] + self.tds.remove_field(field) + self.assertNotEqual(state, self.current_hash()) + + def test_change_aliases(self): + """ Test if the alias changes of a field are reflected in the object and in the underlying XML structure. + """ + field_to_test = "[amount]" + state = self.current_hash() + # change all fields + for key, value in ALIASES.items(): + self.tds.fields[field_to_test].add_alias(key, value) + # the new value must be reflected in the xml + new_state = self.current_hash() + self.assertNotEqual( + state, + new_state, + msg=MESSAGES['test_change_aliases1'].format(field_to_test) + ) + state = new_state + + # check whether all fields of ALIASES have been applied + self.assertEqual( + set(self.tds.fields[field_to_test].aliases), + set(ALIASES), + msg=MESSAGES['test_change_aliases2'].format(field_to_test) + ) + + def test_calculation_base(self): + """ Test if the initial state of calculated fields is correct. + """ + # Demo data has 2 calculated fields at the start + original_len = len(self.tds.calculations) + + # Can't write to calculation for not-calculated fields! + self.tds.fields['[name]'].calculation = '1 * 2' + self.assertEqual(len(self.tds.calculations), original_len + 1) + self.tds.fields['[name]'].calculation = '2 * 3' + self.assertEqual(len(self.tds.calculations), original_len + 1) + self.tds.fields['[price]'].calculation = '2 * 3' + self.assertEqual(len(self.tds.calculations), original_len + 2) + + def test_calculation_change(self): + """ Test whether changing calculations of a field works. + """ + state = self.current_hash() + new_calc = '33 * 44' + fld_name = '[Calculation_357754699576291328]' + self.tds.calculations[fld_name].calculation = new_calc + + # Check object representation + self.assertEqual(self.tds.calculations[fld_name].calculation, new_calc) + + # Check XML representation + new_state = self.current_hash() + self.assertNotEqual(state, new_state) + + def test_calculation_new(self): + """ Test if creating a new calculation works. + """ + args = 'TestCalc', '12*34', 'integer', 'measure', 'quantitative' + original_len = len(self.tds.calculations) + self.tds.add_calculation(*args) + self.assertEqual(len(self.tds.calculations), original_len + 1) + + def test_calculation_remove(self): + """ Test if deleting a calculation works. + """ + args = 'TestCalc2', '12*34', 'integer', 'measure', 'quantitative' + + original_len = len(self.tds.calculations) + calc = self.tds.add_calculation(*args) + self.assertEqual(len(self.tds.calculations), original_len + 1) + + self.tds.remove_field(calc) + self.assertEqual(len(self.tds.calculations), original_len) + + def tearDown(self): + """ Test if the file can be saved. + Output file will be ignored by git, but can be used to verify the results. + """ + self.tds.save_as(TEST_TDS_FILE_OUTPUT) + + +if __name__ == '__main__': + unittest.main() From ef10f9da99c85967c519776e0840157e0416cd01 Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 29 Jan 2017 12:45:39 +0100 Subject: [PATCH 2/4] Implemented change requests --- tableaudocumentapi/datasource.py | 13 ++--- tableaudocumentapi/field.py | 28 +++-------- tableaudocumentapi/property_decorators.py | 15 ++++++ tableaudocumentapi/xfile.py | 10 ++++ test/test_field_change.py | 59 ++++++++++++----------- 5 files changed, 64 insertions(+), 61 deletions(-) create mode 100644 tableaudocumentapi/property_decorators.py diff --git a/tableaudocumentapi/datasource.py b/tableaudocumentapi/datasource.py index 1e7f64f..aff1a43 100644 --- a/tableaudocumentapi/datasource.py +++ b/tableaudocumentapi/datasource.py @@ -262,14 +262,14 @@ def _get_column_objects(self): return [_column_object_from_column_xml(self._datasourceTree, xml) for xml in self._datasourceTree.findall('.//column')] - def add_field(self, name, datatype, role, type, caption): + def add_field(self, name, datatype, role, field_type, caption): """ Adds a base field object with the given values. Args: name: Name of the new Field. String. datatype: Datatype of the new field. String. role: Role of the new field. String. - type: Type of the new field. String. + field_type: Type of the new field. String. caption: Caption of the new field. String. Returns: @@ -284,12 +284,7 @@ def add_field(self, name, datatype, role, type, caption): caption = name.replace('[', '').replace(']', '').title() # Create the elements - column = ET.Element('column') - column.set('caption', caption) - column.set('datatype', datatype) - column.set('role', role) - column.set('type', type) - column.set('name', name) + column = xfile.create_column(caption, datatype, role, field_type, name) self._datasourceTree.getroot().append(column) @@ -319,8 +314,6 @@ def remove_field(self, field): def calculations(self): """ Returns all calculated fields. """ - # TODO: There is a default [Number of Records] calculation. - # Should this be excluded so users can't meddle with it? return {k: v for k, v in self.fields.items() if v.calculation is not None} def add_calculation(self, caption, formula, datatype, role, type): diff --git a/tableaudocumentapi/field.py b/tableaudocumentapi/field.py index e924e72..b989e7d 100644 --- a/tableaudocumentapi/field.py +++ b/tableaudocumentapi/field.py @@ -1,6 +1,7 @@ import functools import xml.etree.ElementTree as ET -from functools import wraps + +from tableaudocumentapi.property_decorators import argument_is_one_of _ATTRIBUTES = [ 'id', # Name of the field as specified in the file, usually surrounded by [ ] @@ -24,21 +25,6 @@ ] -def argument_is_one_of(*allowed_values): - def property_type_decorator(func): - @wraps(func) - def wrapper(self, value): - if value not in allowed_values: - error = "Invalid argument: {0}. {1} must be one of {2}." - msg = error.format(value, func.__name__, allowed_values) - raise ValueError(error) - return func(self, value) - - return wrapper - - return property_type_decorator - - def _find_metadata_record(record, attrib): element = record.find('.//{}'.format(attrib)) if element is None: @@ -58,8 +44,6 @@ def __init__(self, column_xml=None, metadata_xml=None): setattr(self, '_{}'.format(attrib), None) self._worksheets = set() - self._xml = None - if column_xml is not None: self._initialize_from_column_xml(column_xml) self._xml = column_xml @@ -225,17 +209,17 @@ def type(self): @type.setter @argument_is_one_of('quantitative', 'ordinal', 'nominal') - def type(self, type): + def type(self, field_type): """ Set the type of a field Args: - type: New type. String. + field_type: New type. String. Returns: Nothing. """ - self._type = type - self._xml.set('type', type) + self._type = field_type + self._xml.set('type', field_type) ######################################## # Aliases getter and setter diff --git a/tableaudocumentapi/property_decorators.py b/tableaudocumentapi/property_decorators.py new file mode 100644 index 0000000..209295f --- /dev/null +++ b/tableaudocumentapi/property_decorators.py @@ -0,0 +1,15 @@ +from functools import wraps + +def argument_is_one_of(*allowed_values): + def property_type_decorator(func): + @wraps(func) + def wrapper(self, value): + if value not in allowed_values: + error = "Invalid argument: {0}. {1} must be one of {2}." + msg = error.format(value, func.__name__, allowed_values) + raise ValueError(error) + return func(self, value) + + return wrapper + + return property_type_decorator diff --git a/tableaudocumentapi/xfile.py b/tableaudocumentapi/xfile.py index 3067781..406a261 100644 --- a/tableaudocumentapi/xfile.py +++ b/tableaudocumentapi/xfile.py @@ -70,6 +70,16 @@ def get_xml_from_archive(filename): return xml_tree +def create_column(caption, datatype, role, field_type, name): + column = ET.Element('column') + column.set('caption', caption) + column.set('datatype', datatype) + column.set('role', role) + column.set('type', field_type) + column.set('name', name) + return column + + def build_archive_file(archive_contents, zip_file): for root_dir, _, files in os.walk(archive_contents): relative_dir = os.path.relpath(root_dir, archive_contents) diff --git a/test/test_field_change.py b/test/test_field_change.py index 6acd7b7..7a84f1e 100644 --- a/test/test_field_change.py +++ b/test/test_field_change.py @@ -63,11 +63,31 @@ def current_hash(self): """ return hash(ET.tostring(self.tds._datasourceTree.getroot())) + def check_state_change(self, should_change, msg, field_name): + """ Check whether the XML has changed and update the current state. + + Args: + should_change: Whether the XML is supposed to have changed or not. Boolean. + msg: The message to be displayed in an error case, as key for the MESSAGES dict. String. + field_name: The field name that will be displayed in the error message. String. + + Returns: + Nothing. + """ + new_state = self.current_hash() + compare_func = self.assertNotEqual if should_change else self.assertEqual + compare_func( + self.state, + new_state, + msg=MESSAGES[msg].format(field_name) + ) + self.state = new_state + def test_change_values(self): """ Test if the value changes of a field are reflected in the object and in the underlying XML structure. """ field_to_test = "[amount]" - state = self.current_hash() + self.state = self.current_hash() # change all fields for key, value in NEW_VALUES.items(): setattr(self.tds.fields[field_to_test], key, value) @@ -78,19 +98,13 @@ def test_change_values(self): msg=MESSAGES['test_change_values1'].format(key) ) # the new value must be reflected in the xml - new_state = self.current_hash() - self.assertNotEqual( - state, - new_state, - msg=MESSAGES['test_change_values2'].format(key) - ) - state = new_state + self.check_state_change(True, 'test_change_values2', key) def test_change_values_fail(self): """ Test if the value changes of a field are rejected if the wrong arguments are passed. """ field_to_test = "[amount]" - state = self.current_hash() + self.state = self.current_hash() # change all fields for key, value in WRONG_VALUES.items(): @@ -104,42 +118,29 @@ def test_change_values_fail(self): value, msg=MESSAGES['test_change_valuesFail1'].format(key) ) - # the new value must NOT be reflected in the xml - new_state = self.current_hash() - self.assertEqual( - state, - new_state, - msg=MESSAGES['test_change_valuesFail2'].format(key) - ) - state = new_state + self.check_state_change(False, 'test_change_valuesFail2', key) def test_remove_field(self): """ Test if a Field can be removed. """ field_to_test = "[amount]" - state = self.current_hash() + self.state = self.current_hash() # change all fields field = self.tds.fields["[amount]"] self.tds.remove_field(field) - self.assertNotEqual(state, self.current_hash()) + self.assertNotEqual(self.state, self.current_hash()) def test_change_aliases(self): """ Test if the alias changes of a field are reflected in the object and in the underlying XML structure. """ field_to_test = "[amount]" - state = self.current_hash() + self.state = self.current_hash() # change all fields for key, value in ALIASES.items(): self.tds.fields[field_to_test].add_alias(key, value) # the new value must be reflected in the xml - new_state = self.current_hash() - self.assertNotEqual( - state, - new_state, - msg=MESSAGES['test_change_aliases1'].format(field_to_test) - ) - state = new_state + self.check_state_change(True, 'test_change_aliases1', field_to_test) # check whether all fields of ALIASES have been applied self.assertEqual( @@ -165,7 +166,7 @@ def test_calculation_base(self): def test_calculation_change(self): """ Test whether changing calculations of a field works. """ - state = self.current_hash() + self.state = self.current_hash() new_calc = '33 * 44' fld_name = '[Calculation_357754699576291328]' self.tds.calculations[fld_name].calculation = new_calc @@ -175,7 +176,7 @@ def test_calculation_change(self): # Check XML representation new_state = self.current_hash() - self.assertNotEqual(state, new_state) + self.assertNotEqual(self.state, new_state) def test_calculation_new(self): """ Test if creating a new calculation works. From 8293bb3274bc1d6da154f27e98b373109e545d19 Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 29 Jan 2017 13:02:17 +0100 Subject: [PATCH 3/4] fixed pycodestyle error in property_decorators.py and bvt.py --- tableaudocumentapi/property_decorators.py | 1 + test/bvt.py | 1 + 2 files changed, 2 insertions(+) diff --git a/tableaudocumentapi/property_decorators.py b/tableaudocumentapi/property_decorators.py index 209295f..aa82122 100644 --- a/tableaudocumentapi/property_decorators.py +++ b/tableaudocumentapi/property_decorators.py @@ -1,5 +1,6 @@ from functools import wraps + def argument_is_one_of(*allowed_values): def property_type_decorator(func): @wraps(func) diff --git a/test/bvt.py b/test/bvt.py index e09ec55..8698f7f 100644 --- a/test/bvt.py +++ b/test/bvt.py @@ -393,5 +393,6 @@ def test_82_workbook_throws_exception(self): with self.assertRaises(TableauVersionNotSupportedException): wb = Workbook(TABLEAU_82_TWB) + if __name__ == '__main__': unittest.main() From bd3c60a79d8e4ab8aa6843ce6482223a84921dcd Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 4 Feb 2017 17:05:16 +0100 Subject: [PATCH 4/4] moved the create_column function into fields.py --- tableaudocumentapi/datasource.py | 2 +- tableaudocumentapi/field.py | 10 ++++++++++ tableaudocumentapi/xfile.py | 10 ---------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/tableaudocumentapi/datasource.py b/tableaudocumentapi/datasource.py index aff1a43..d6be628 100644 --- a/tableaudocumentapi/datasource.py +++ b/tableaudocumentapi/datasource.py @@ -284,7 +284,7 @@ def add_field(self, name, datatype, role, field_type, caption): caption = name.replace('[', '').replace(']', '').title() # Create the elements - column = xfile.create_column(caption, datatype, role, field_type, name) + column = Field.create_field_xml(caption, datatype, role, field_type, name) self._datasourceTree.getroot().append(column) diff --git a/tableaudocumentapi/field.py b/tableaudocumentapi/field.py index b989e7d..caab8ac 100644 --- a/tableaudocumentapi/field.py +++ b/tableaudocumentapi/field.py @@ -69,6 +69,16 @@ def _initialize_from_metadata_xml(self, xmldata): read_name=metadata_name) self.apply_metadata(xmldata) + @classmethod + def create_field_xml(cls, caption, datatype, role, field_type, name): + column = ET.Element('column') + column.set('caption', caption) + column.set('datatype', datatype) + column.set('role', role) + column.set('type', field_type) + column.set('name', name) + return column + ######################################## # Special Case methods for construction fields from various sources # not intended for client use diff --git a/tableaudocumentapi/xfile.py b/tableaudocumentapi/xfile.py index 406a261..3067781 100644 --- a/tableaudocumentapi/xfile.py +++ b/tableaudocumentapi/xfile.py @@ -70,16 +70,6 @@ def get_xml_from_archive(filename): return xml_tree -def create_column(caption, datatype, role, field_type, name): - column = ET.Element('column') - column.set('caption', caption) - column.set('datatype', datatype) - column.set('role', role) - column.set('type', field_type) - column.set('name', name) - return column - - def build_archive_file(archive_contents, zip_file): for root_dir, _, files in os.walk(archive_contents): relative_dir = os.path.relpath(root_dir, archive_contents)