diff --git a/tableaudocumentapi/datasource.py b/tableaudocumentapi/datasource.py
index a34cba5..d6be628 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,77 @@ 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, 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.
+ field_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 = Field.create_field_xml(caption, datatype, role, field_type, 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.
+ """
+ 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..caab8ac 100644
--- a/tableaudocumentapi/field.py
+++ b/tableaudocumentapi/field.py
@@ -1,6 +1,7 @@
import functools
import xml.etree.ElementTree as ET
+from tableaudocumentapi.property_decorators import argument_is_one_of
_ATTRIBUTES = [
'id', # Name of the field as specified in the file, usually surrounded by [ ]
@@ -45,12 +46,14 @@ def __init__(self, column_xml=None, metadata_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:
@@ -66,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
@@ -116,52 +129,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, field_type):
+ """ Set the type of a field
+
+ Args:
+ field_type: New type. String.
+
+ Returns:
+ Nothing.
+ """
+ self._type = field_type
+ self._xml.set('type', field_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/tableaudocumentapi/property_decorators.py b/tableaudocumentapi/property_decorators.py
new file mode 100644
index 0000000..aa82122
--- /dev/null
+++ b/tableaudocumentapi/property_decorators.py
@@ -0,0 +1,16 @@
+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/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 @@
+
+
+
+
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: