8000 Added the ability to create and modify fields, including aliases and … by KernpunktAnalytics · Pull Request #111 · tableau/document-api-python · GitHub
[go: up one dir, main page]

Skip to content

Added the ability to create and modify fields, including aliases and … #111

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 78 additions & 1 deletion tableaudocumentapi/datasource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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 :(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What error is it causing?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Field objects init() expects some sort of XML as input. Currently, it's not possible to initialize an "empty" Field and fill it with values later on.


# 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])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we have a function called make_unique or something that does this. I'll take a look to see if I can find it after doing the review.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is one in datasource.py (base36encode), but it's used for a different purpose and the value it returns has a different format than the one required here.

Sidenote: you could replace
ALPHABET = ....
with
ALPHABET = string.digits + string.ascii_lowercase

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I left it the simple way so the function is easy to grok :)

(And I copy pasted from SO)

field = self.add_field(name, datatype, role, type, caption)
field.calculation = formula

return field
167 changes: 164 additions & 3 deletions tableaudocumentapi/field.py
Original file line number Diff line number Diff line change
@@ -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 [ ]
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

type is a reserved word. Per pep8, it should be type_, or something more specific like column_type or field_type

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've removed all 'free floating' variables that are named 'type'.
I didn't change the properties that are named 'type', since they correspond to a XML attribute named 'type' and changing it will throw off anyone using the API. The way it's working, using type() should not be affected by this.

""" 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]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A slightly more idiomatic way to write this is:

existing_alias = next((tag for tag in aliases.findall('alias') if tag.get('key') == key), None)
alias = existing_alias or ET.Element('alias')

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's neat, haven't seen this pattern before :)

# 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)"""
Expand Down
16 changes: 16 additions & 0 deletions tableaudocumentapi/property_decorators.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions test/assets/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
field_change_test_output.tds
Loading
0