-
Notifications
You must be signed in to change notification settings - Fork 182
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
Changes from all commits
8992861
ef10f9d
8293bb3
bd3c60a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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]) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we have a function called There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
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 [ ] | ||
|
@@ -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): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've removed all 'free floating' variables that are named 'type'. |
||
""" 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] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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') There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)""" | ||
|
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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
field_change_test_output.tds |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.