Skip to content

Commit aa93eef

Browse files
author
Russell Hay
authored
Add a version check to provide good errors on version incompatibility (#59)
* add an explicit version check to be explicit about what we support * Move xml_open to xfile * Updating to ignore ephemeral fields * scrubbing thumbnails from the new twb file
1 parent 1f953d9 commit aa93eef

File tree

6 files changed

+243
-19
lines changed

6 files changed

+243
-19
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Document API
99
The Document API provides a supported way to programmatically make updates to Tableau workbook and data source files. If you've been making changes to these file types by directly updating the XML--that is, by XML hacking--this SDK is for you :)
1010

1111
Features include:
12-
- Support for 8.X, 9.X, and 10.X workbook and data source files
12+
- Support for 9.X, and 10.X workbook and data source files
1313
- Including TDSX and TWBX files
1414
- Getting connection information from data sources and workbooks
1515
- Server Name

tableaudocumentapi/datasource.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@
77
import itertools
88
import xml.etree.ElementTree as ET
99
import xml.sax.saxutils as sax
10-
import zipfile
1110

1211
from tableaudocumentapi import Connection, xfile
1312
from tableaudocumentapi import Field
1413
from tableaudocumentapi.multilookup_dict import MultiLookupDict
14+
from tableaudocumentapi.xfile import xml_open
1515

1616
########
1717
# This is needed in order to determine if something is a string or not. It is necessary because
@@ -113,10 +113,7 @@ def __init__(self, dsxml, filename=None):
113113
def from_file(cls, filename):
114114
"""Initialize datasource from file (.tds)"""
115115

116-
if zipfile.is_zipfile(filename):
117-
dsxml = xfile.get_xml_from_archive(filename).getroot()
118-
else:
119-
dsxml = ET.parse(filename).getroot()
116+
dsxml = xml_open(filename).getroot()
120117
return cls(dsxml, filename)
121118

122119
def save(self):

tableaudocumentapi/workbook.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import xml.etree.ElementTree as ET
1111

1212
from tableaudocumentapi import Datasource, xfile
13+
from tableaudocumentapi.xfile import xml_open
1314

1415

1516
class Workbook(object):
@@ -31,12 +32,7 @@ def __init__(self, filename):
3132

3233
self._filename = filename
3334

34-
# Determine if this is a twb or twbx and get the xml root
35-
if zipfile.is_zipfile(self._filename):
36-
self._workbookTree = xfile.get_xml_from_archive(
37-
self._filename)
38-
else:
39-
self._workbookTree = ET.parse(self._filename)
35+
self._workbookTree = xml_open(self._filename)
4036

4137
self._workbookRoot = self._workbookTree.getroot()
4238
# prepare our datasource objects
@@ -145,6 +141,7 @@ def _prepare_worksheets(xml_root, ds_index):
145141
datasource = ds_index[datasource_name]
146142
for column in dependency.findall('.//column'):
147143
column_name = column.attrib['name']
148-
datasource.fields[column_name].add_used_in(worksheet_name)
144+
if column_name in datasource.fields:
145+
datasource.fields[column_name].add_used_in(worksheet_name)
149146

150147
return worksheets

tableaudocumentapi/xfile.py

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,31 @@
33
import shutil
44
import tempfile
55
import zipfile
6-
76
import xml.etree.ElementTree as ET
87

8+
try:
9+
from distutils2.version import NormalizedVersion as Version
10+
except ImportError:
11+
from distutils.version import LooseVersion as Version
12+
13+
MIN_SUPPORTED_VERSION = Version("9.0")
14+
15+
16+
class TableauVersionNotSupportedException(Exception):
17+
pass
18+
19+
20+
def xml_open(filename):
21+
# Determine if this is a twb or twbx and get the xml root
22+
if zipfile.is_zipfile(filename):
23+
tree = get_xml_from_archive(filename)
24+
else:
25+
tree = ET.parse(filename)
26+
file_version = Version(tree.getroot().attrib.get('version', '0.0'))
27+
if file_version < MIN_SUPPORTED_VERSION:
28+
raise TableauVersionNotSupportedException(file_version)
29+
return tree
30+
931

1032
@contextlib.contextmanager
1133
def temporary_directory(*args, **kwargs):
@@ -16,10 +38,10 @@ def temporary_directory(*args, **kwargs):
1638
shutil.rmtree(d)
1739

1840

19-
def find_file_in_zip(zip):
20-
for filename in zip.namelist():
41+
def find_file_in_zip(zip_file):
42+
for filename in zip_file.namelist():
2143
try:
22-
with zip.open(filename) as xml_candidate:
44+
with zip_file.open(filename) as xml_candidate:
2345
ET.parse(xml_candidate).getroot().tag in (
2446
'workbook', 'datasource')
2547
return filename
@@ -36,14 +58,14 @@ def get_xml_from_archive(filename):
3658
return xml_tree
3759

3860

39-
def build_archive_file(archive_contents, zip):
61+
def build_archive_file(archive_contents, zip_file):
4062
for root_dir, _, files in os.walk(archive_contents):
4163
relative_dir = os.path.relpath(root_dir, archive_contents)
4264
for f in files:
4365
temp_file_full_path = os.path.join(
4466
archive_contents, relative_dir, f)
4567
zipname = os.path.join(relative_dir, f)
46-
zip.write(temp_file_full_path, arcname=zipname)
68+
zip_file.write(temp_file_full_path, arcname=zipname)
4769

4870

4971
def save_into_archive(xml_tree, filename, new_filename=None):

test/assets/ephemeral_field.twb

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
<?xml version='1.0' encoding='utf-8' ?>
2+
3+
<!-- build 9300.16.0603.2240 -->
4+
<workbook source-build='9.3.3 (9300.16.0603.2240)' source-platform='mac' version='9.3' xmlns:user='http://www.tableausoftware.com/xml/user'>
5+
<preferences>
6+
<preference name='ui.encoding.shelf.height' value='24' />
7+
<preference name='ui.shelf.height' value='26' />
8+
</preferences>
9+
<datasources>
10+
<datasource inline='true' name='datasource_test' version='9.3'>
11+
<connection authentication='username-password' class='postgres' dbname='TestV1' odbc-native-protocol='yes' port='5432' server='postgres91.test.tsi.lan' username='test'>
12+
<relation name='xy' table='[public].[xy]' type='table' />
13+
<metadata-records>
14+
<metadata-record class='column'>
15+
<remote-name>a</remote-name>
16+
<remote-type>130</remote-type>
17+
<local-name>[a]</local-name>
18+
<parent-name>[xy]</parent-name>
19+
<remote-alias>a</remote-alias>
20+
<ordinal>1</ordinal>
21+
<local-type>string</local-type>
22+
<aggregation>Count</aggregation>
23+
<width>255</width>
24+
<contains-null>true</contains-null>
25+
<attributes>
26+
<attribute datatype='string' name='DebugRemoteType'>&quot;SQL_WVARCHAR&quot;</attribute>
27+
<attribute datatype='string' name='DebugWireType'>&quot;SQL_C_WCHAR&quot;</attribute>
28+
<attribute datatype='string' name='TypeIsVarchar'>&quot;true&quot;</attribute>
29+
</attributes>
30+
</metadata-record>
31+
<metadata-record class='column'>
32+
<remote-name>x</remote-name>
33+
<remote-type>3</remote-type>
34+
<local-name>[x]</local-name>
35+
<parent-name>[xy]</parent-name>
36+
<remote-alias>x</remote-alias>
37+
<ordinal>2</ordinal>
38+
<local-type>integer</local-type>
39+
<aggregation>Sum</aggregation>
40+
<precision>10</precision>
41+
<contains-null>true</contains-null>
42+
<attributes>
43+
<attribute datatype='string' name='DebugRemoteType'>&quot;SQL_INTEGER&quot;</attribute>
44+
<attribute datatype='string' name='DebugWireType'>&quot;SQL_C_SLONG&quot;</attribute>
45+
</attributes>
46+
</metadata-record>
47+
<metadata-record class='column'>
48+
<remote-name>y</remote-name>
49+
<remote-type>3</remote-type>
50+
<local-name>[y]</local-name>
51+
<parent-name>[xy]</parent-name>
52+
<remote-alias>y</remote-alias>
53+
<ordinal>3</ordinal>
54+
<local-type>integer</local-type>
55+
<aggregation>Sum</aggregation>
56+
<precision>10</precision>
57+
<contains-null>true</contains-null>
58+
<attributes>
59+
<attribute datatype='string' name='DebugRemoteType'>&quot;SQL_INTEGER&quot;</attribute>
60+
<attribute datatype='string' name='DebugWireType'>&quot;SQL_C_SLONG&quot;</attribute>
61+
</attributes>
62+
</metadata-record>
63+
</metadata-records>
64+
</connection>
65+
<aliases enabled='yes' />
66+
<column datatype='integer' name='[Number of Records]' role='measure' type='quantitative' user:auto-column='numrec'>
67+
<calculation class='tableau' formula='1' />
68+
</column>
69+
<column caption='A' datatype='string' name='[a]' role='dimension' type='nominal' />
70+
<column caption='X' datatype='integer' name='[x]' role='measure' type='quantitative' />
71+
<column caption='Y' datatype='integer' name='[y]' role='measure' type='quantitative' />
72+
<layout dim-ordering='alphabetic' dim-percentage='0.48' measure-ordering='alphabetic' measure-percentage='0.52' show-structure='true' />
73+
<semantic-values>
74+
<semantic-value key='[Country].[Name]' value='&quot;United States&quot;' />
75+
</semantic-values>
76+
</datasource>
77+
</datasources>
78+
<worksheets>
79+
<worksheet name='Sheet 1'>
80+
<table>
81+
<view>
82+
<datasources>
83+
<datasource name='datasource_test' />
84+
</datasources>
85+
<datasource-dependencies datasource='datasource_test'>
86+
<column caption='A' datatype='string' name='[a]' role='dimension' type='nominal' />
87+
<column-instance column='[a]' derivation='None' name='[none:a:nk]' pivot='key' type='nominal' />
88+
</datasource-dependencies>
89+
<aggregation value='true' />
90+
</view>
91+
<style />
92+
<panes>
93+
<pane>
94+
<view>
95+
<breakdown value='auto' />
96+
</view>
97+
<mark class='Automatic' />
98+
</pane>
99+
</panes>
100+
<rows>[datasource_test].[none:a:nk]</rows>
101+
<cols />
102+
</table>
103+
</worksheet>
104+
<worksheet name='Sheet 2'>
105+
<table>
106+
<view>
107+
<datasources>
108+
<datasource name='datasource_test' />
109+
</datasources>
110+
<datasource-dependencies datasource='datasource_test'>
111+
<column caption='SUM(X) + SUM(Y)' datatype='integer' name='[Calculation_3002775091869110273]' role='measure' type='quantitative' user:unnamed='Sheet 2'>
112+
<calculation class='tableau' formula='SUM([x]) + SUM([y])' />
113+
</column>
114+
<column caption='A' datatype='string' name='[a]' role='dimension' type='nominal' />
115+
<column-instance column='[a]' derivation='None' name='[none:a:nk]' pivot='key' type='nominal' />
116+
<column-instance column='[x]' derivation='Sum' name='[sum:x:qk]' pivot='key' type='quantitative' />
117+
<column-instance column='[Calculation_3002775091869110273]' derivation='User' name='[usr:Calculation_3002775091869110273:qk]' pivot='key' type='quantitative' />
118+
<column caption='X' datatype='integer' name='[x]' role='measure' type='quantitative' />
119+
<column caption='Y' datatype='integer' name='[y]' role='measure' type='quantitative' />
120+
</datasource-dependencies>
121+
<aggregation value='true' />
122+
</view>
123+
<style />
124+
<panes>
125+
<pane>
126+
<view>
127+
<breakdown value='auto' />
128+
</view>
129+
<mark class='Automatic' />
130+
</pane>
131+
<pane id='1' x-axis-name='[datasource_test].[sum:x:qk]'>
132+
<view>
133+
<breakdown value='auto' />
134+
</view>
135+
<mark class='Automatic' />
136+
</pane>
137+
<pane id='2' x-axis-name='[datasource_test].[usr:Calculation_3002775091869110273:qk]'>
138+
<view>
139+
<breakdown value='auto' />
140+
</view>
141+
<mark class='Automatic' />
142+
</pane>
143+
</panes>
144+
<rows>[datasource_test].[none:a:nk]</rows>
145+
<cols>([datasource_test].[sum:x:qk] + [datasource_test].[usr:Calculation_3002775091869110273:qk])</cols>
146+
</table>
147+
</worksheet>
148+
</worksheets>
149+
<windows source-height='28'>
150+
<window class='worksheet' name='Sheet 1'>
151+
<cards>
152+
<edge name='left'>
153+
<strip size='160'>
154+
<card type='pages' />
155+
<card type='filters' />
156+
<card type='marks' />
157+
</strip>
158+
</edge>
159+
<edge name='top'>
160+
<strip size='2147483647'>
161+
<card type='columns' />
162+
</strip>
163+
<strip size='2147483647'>
164+
<card type='rows' />
165+
</strip>
166+
</edge>
167+
</cards>
168+
</window>
169+
<window class='worksheet' maximized='true' name='Sheet 2'>
170+
<cards>
171+
<edge name='left'>
172+
<strip size='160'>
173+
<card type='pages' />
174+
<card type='filters' />
175+
<card type='marks' />
176+
</strip>
177+
</edge>
178+
<edge name='top'>
179+
<strip size='2147483647'>
180+
<card type='columns' />
181+
</strip>
182+
<strip size='2147483647'>
183+
<card type='rows' />
184+
</strip>
185+
</edge>
186+
</cards>
187+
</window>
188+
</windows>
189+
</workbook>

test/test_workbook.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import unittest
2+
import os.path
3+
4+
from tableaudocumentapi import Datasource, Workbook
5+
6+
TEST_ASSET_DIR = os.path.join(
7+
os.path.dirname(__file__),
8+
'assets'
9+
)
10+
EPHEMERAL_FIELD_FILE = os.path.join(
11+
TEST_ASSET_DIR,
12+
'ephemeral_field.twb'
13+
)
14+
15+
16+
class EphemeralFields(unittest.TestCase):
17+
def test_ephemeral_fields_do_not_cause_errors(self):
18+
wb = Workbook(EPHEMERAL_FIELD_FILE)
19+
self.assertIsNotNone(wb)

0 commit comments

Comments
 (0)