8000 Added the ability to create and modify fields, including aliases and … · onware/document-api-python@562a54e · GitHub
[go: up one dir, main page]

Skip to content

Commit 562a54e

Browse files
KernpunktAnalyticst8y8
authored andcommitted
Added the ability to create and modify fields, including aliases and … (tableau#111)
* Added the ability to create and modify fields, including aliases and calculated fields
1 parent 10e13b9 commit 562a54e

File tree

6 files changed

+571
-4
lines changed

6 files changed

+571
-4
lines changed

tableaudocumentapi/datasource.py

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,9 +225,12 @@ def clear_repository_location(self):
225225
@property
226226
def fields(self):
227227
if not self._fields:
228-
self._fields = self._get_all_fields()
228+
self._refresh_fields()
229229
return self._fields
230230

231+
def _refresh_fields(self):
232+
self._fields = self._get_all_fields()
233+
231234
def _get_all_fields(self):
232235
# Some columns are represented by `column` tags and others as `metadata-record` tags
233236
# Find them all and chain them into one dictionary
@@ -245,3 +248,77 @@ def _get_metadata_objects(self):
245248
def _get_column_objects(self):
246249
return [_column_object_from_column_xml(self._datasourceTree, xml)
247250
for xml in self._datasourceTree.findall('.//column')]
251+
252+
def add_field(self, name, datatype, role, field_type, caption):
253+
""" Adds a base field object with the given values.
254+
255+
Args:
256+
name: Name of the new Field. String.
257+
datatype: Datatype of the new field. String.
258+
role: Role of the new field. String.
259+
field_type: Type of the new field. String.
260+
caption: Caption of the new field. String.
261+
262+
Returns:
263+
The new field that was created. Field.
264+
"""
265+
# TODO: A better approach would be to create an empty column and then
266+
# use the input validation from its "Field"-object-representation to set values.
267+
# However, creating an empty column causes errors :(
268+
269+
# If no caption is specified, create one with the same format Tableau does
270+
if not caption:
271+
caption = name.replace('[', '').replace(']', '').title()
272+
273+
# Create the elements
274+
column = Field.create_field_xml(caption, datatype, role, field_type, name)
275+
276+
self._datasourceTree.getroot().append(column)
277+
278+
# Refresh fields to reflect changes and return the Field object
279+
self._refresh_fields()
280+
return self.fields[name]
281+
282+
def remove_field(self, field):
283+
""" Remove a given field
284+
285+
Args:
286+
field: The field to remove. ET.Element
287+
288+
Returns:
289+
None
290+
"""
291+
if not field or not isinstance(field, Field):
292+
raise ValueError("Need to supply a field to remove element")
293+
294+
self._datasourceTree.getroot().remove(field.xml)
295+
self._refresh_fields()
296+
297+
###########
298+
# Calculations
299+
###########
300+
@property
301+
def calculations(self):
302+
""" Returns all calculated fields.
303+
"""
304+
return {k: v for k, v in self.fields.items() if v.calculation is not None}
305+
306+
def add_calculation(self, caption, formula, datatype, role, type):
307+
""" Adds a calculated field with the given values.
308+
309+
Args:
310+
caption: Caption of the new calculation. String.
311+
formula: Formula of the new calculation. String.
312+
datatype: Datatype of the new calculation. String.
313+
role: Role of the new calculation. String.
314+
type: Type of the new calculation. String.
315+
316+
Returns:
317+
The new calculated field that was created. Field.
318+
"""
319+
# Dynamically create the name of the field
320+
name = '[Calculation_{}]'.format(str(uuid4().int)[:18])
321+
field = self.add_field(name, datatype, role, type, caption)
322+
field.calculation = formula
323+
324+
return field

tableaudocumentapi/field.py

Lines changed: 164 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import functools
22
import xml.etree.ElementTree as ET
33

4+
from tableaudocumentapi.property_decorators import argument_is_one_of
45

56
_ATTRIBUTES = [
67
'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):
4546

4647
if column_xml is not None:
4748
self._initialize_from_column_xml(column_xml)
49+
self._xml = column_xml
4850
# This isn't currently never called because of the way we get the data from the xml,
4951
# but during the refactor, we might need it. This is commented out as a reminder
5052
# if metadata_xml is not None:
5153
# self.apply_metadata(metadata_xml)
5254

5355
elif metadata_xml is not None:
56+
self._xml = metadata_xml
5457
self._initialize_from_metadata_xml(metadata_xml)
5558

5659
else:
@@ -66,6 +69,16 @@ def _initialize_from_metadata_xml(self, xmldata):
6669
read_name=metadata_name)
6770
self.apply_metadata(xmldata)
6871

72+
@classmethod
73+
def create_field_xml(cls, caption, datatype, role, field_type, name):
74+
column = ET.Element('column')
75+
column.set('caption', caption)
76+
column.set('datatype', datatype)
77+
column.set('role', role)
78+
column.set('type', field_type)
79+
column.set('name', name)
80+
return column
81+
6982
########################################
7083
# Special Case methods for construction fields from various sources
7184
# not intended for client use
@@ -116,52 +129,200 @@ def id(self):
116129
""" Name of the field as specified in the file, usually surrounded by [ ] """
117130
return self._id
118131

132+
@property
133+
def xml(self):
134+
""" XML representation of the field. """
135+
return self._xml
136+
137+
########################################
138+
# Attribute getters and setters
139+
########################################
140+
119141
@property
120142
def caption(self):
121143
""" Name of the field as displayed in Tableau unless an aliases is defined """
122144
return self._caption
123145

146+
@caption.setter
147+
def caption(self, caption):
148+
""" Set the caption of a field
149+
150+
Args:
151+
caption: New caption. String.
152+
153+
Returns:
154+
Nothing.
155+
"""
156+
self._caption = caption
157+
self._xml.set('caption', caption)
158+
124159
@property
125160
def alias(self):
126161
""" Name of the field as displayed in Tableau if the default name isn't wanted """
127162
return self._alias
128163

164+
@alias.setter
165+
def alias(self, alias):
166+
""" Set the alias of a field
167+
168+
Args:
169+
alias: New alias. String.
170+
171+
Returns:
172+
Nothing.
173+
"""
174+
self._alias = alias
175+
self._xml.set('alias', alias)
176+
129177
@property
130178
def datatype(self):
131179
""" Type of the field within Tableau (string, integer, etc) """
132180
return self._datatype
133181

182+
@datatype.setter
183+
@argument_is_one_of('string', 'integer', 'date', 'boolean')
184+
def datatype(self, datatype):
185+
""" Set the datatype of a field
186+
187+
Args:
188+
datatype: New datatype. String.
189+
190+
Returns:
191+
Nothing.
192+
"""
193+
self._datatype = datatype
194+
self._xml.set('datatype', datatype)
195+
134196
@property
135197
def role(self):
136198
""" Dimension or Measure """
137199
return self._role
138200

201+
@role.setter
202+
@argument_is_one_of('dimension', 'measure')
203+
def role(self, role):
204+
""" Set the role of a field
205+
206+
Args:
207+
role: New role. String.
208+
209+
Returns:
210+
Nothing.
211+
"""
212+
self._role = role
213+
self._xml.set('role', role)
214+
215+
@property
216+
def type(self):
217+
""" Dimension or Measure """
218+
return self._type
219+
220+
@type.setter
221+
@argument_is_one_of('quantitative', 'ordinal', 'nominal')
222+
def type(self, field_type):
223+
""" Set the type of a field
224+
225+
Args:
226+
field_type: New type. String.
227+
228+
Returns:
229+
Nothing.
230+
"""
231+
self._type = field_type
232+
self._xml.set('type', field_type)
233+
234+
########################################
235+
# Aliases getter and setter
236+
# Those are NOT the 'alias' field of the column,
237+
# but instead the key-value aliases in its child elements
238+
########################################
239+
240+
def add_alias(self, key, value):
241+
""" Add an alias for a given display value.
242+
243+
Args:
244+
key: The data value to map. Example: "1". String.
245+
value: The display value for the key. Example: "True". String.
246+
Returns:
247+
Nothing.
248+
"""
249+
250+
# determine whether there already is an aliases-tag
251+
aliases = self._xml.find('aliases')
252+
# and create it if there isn't
253+
if not aliases:
254+
aliases = ET.Element('aliases')
255+
self._xml.append(aliases)
256+
257+
# find out if an alias with this key already exists and use it
258+
existing_alias = [tag for tag in aliases.findall('alias') if tag.get('key') == key]
259+
# if not, create a new ET.Element
260+
alias = existing_alias[0] if existing_alias else ET.Element('alias')
261+
262+
alias.set('key', key)
263+
alias.set('value', value)
264+
if not existing_alias:
265+
aliases.append(alias)
266+
267+
@property
268+
def aliases(self):
269+
""" Returns all aliases that are registered under this field.
270+
271 10000 +
Returns:
272+
Key-value mappings of all registered aliases. Dict.
273+
"""
274+
aliases_tag = self._xml.find('aliases') or []
275+
return {a.get('key', 'None'): a.get('value', 'None') for a in list(aliases_tag)}
276+
277+
########################################
278+
# Attribute getters
279+
########################################
280+
139281
@property
140282
def is_quantitative(self):
141283
""" A dependent value, usually a measure of something
142284
143285
e.g. Profit, Gross Sales """
144-
return self._type == 'quantitative'
286+
return self.type == 'quantitative'
145287

146288
@property
147289
def is_ordinal(self):
148290
""" Is this field a categorical field that has a specific order
149291
150292
e.g. How do you feel? 1 - awful, 2 - ok, 3 - fantastic """
151-
return self._type == 'ordinal'
293+
return self.type == 'ordinal'
152294

153295
@property
154296
def is_nominal(self):
155297
""" Is this field a categorical field that does not have a specific order
156298
157299
e.g. What color is your hair? """
158-
return self._type == 'nominal'
300+
return self.type == 'nominal'
159301

160302
@property
161303
def calculation(self):
162304
""" If this field is a calculated field, this will be the formula """
163305
return self._calculation
164306

307+
@calculation.setter
308+
def calculation(self, new_calculation):
309+
""" Set the calculation of a calculated field.
310+
311+
Args:
312+
new_calculation: The new calculation/formula of the field. String.
313+
"""
314+
if self.calculation is None:
315+
calculation = ET.Element('calculation')
316+
calculation.set('class', 'tableau')
317+
calculation.set('formula', new_calculation)
318+
# Append the elements to the respective structure
319+
self._xml.append(calculation)
320+
321+
else:
322+
self._xml.find('calculation').set('formula', new_calculation)
323+
324+
self._calculation = new_calculation
325+
165326
@property
166327
def default_aggregation(self):
167328
""" The default type of aggregation on the field (e.g Sum, Avg)"""
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from functools import wraps
2+
3+
4+
def argument_is_one_of(*allowed_values):
5+
def property_type_decorator(func):
6+
@wraps(func)
7+
def wrapper(self, value):
8+
if value not in allowed_values:
9+
error = "Invalid argument: {0}. {1} must be one of {2}."
10+
msg = error.format(value, func.__name__, allowed_values)
11+
raise ValueError(error)
12+
return func(self, value)
13+
14+
return wrapper
15+
16+
return property_type_decorator

test/assets/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
field_change_test_output.tds

0 commit comments

Comments
 (0)
0