tableauserverclient 0.33__py3-none-any.whl → 0.35__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tableauserverclient/__init__.py +33 -23
- tableauserverclient/{_version.py → bin/_version.py} +3 -3
- tableauserverclient/config.py +5 -3
- tableauserverclient/models/column_item.py +1 -1
- tableauserverclient/models/connection_credentials.py +18 -2
- tableauserverclient/models/connection_item.py +44 -6
- tableauserverclient/models/custom_view_item.py +78 -11
- tableauserverclient/models/data_acceleration_report_item.py +2 -2
- tableauserverclient/models/data_alert_item.py +5 -5
- tableauserverclient/models/data_freshness_policy_item.py +6 -6
- tableauserverclient/models/database_item.py +3 -3
- tableauserverclient/models/datasource_item.py +10 -10
- tableauserverclient/models/dqw_item.py +1 -1
- tableauserverclient/models/favorites_item.py +5 -6
- tableauserverclient/models/fileupload_item.py +1 -1
- tableauserverclient/models/flow_item.py +54 -9
- tableauserverclient/models/flow_run_item.py +3 -3
- tableauserverclient/models/group_item.py +44 -4
- tableauserverclient/models/groupset_item.py +4 -4
- tableauserverclient/models/interval_item.py +9 -9
- tableauserverclient/models/job_item.py +73 -8
- tableauserverclient/models/linked_tasks_item.py +5 -5
- tableauserverclient/models/metric_item.py +5 -5
- tableauserverclient/models/pagination_item.py +1 -1
- tableauserverclient/models/permissions_item.py +12 -10
- tableauserverclient/models/project_item.py +73 -19
- tableauserverclient/models/property_decorators.py +12 -11
- tableauserverclient/models/reference_item.py +2 -2
- tableauserverclient/models/revision_item.py +3 -3
- tableauserverclient/models/schedule_item.py +2 -2
- tableauserverclient/models/server_info_item.py +26 -6
- tableauserverclient/models/site_item.py +69 -3
- tableauserverclient/models/subscription_item.py +3 -3
- tableauserverclient/models/table_item.py +1 -1
- tableauserverclient/models/tableau_auth.py +115 -5
- tableauserverclient/models/tableau_types.py +2 -2
- tableauserverclient/models/tag_item.py +3 -4
- tableauserverclient/models/task_item.py +34 -4
- tableauserverclient/models/user_item.py +47 -17
- tableauserverclient/models/view_item.py +66 -13
- tableauserverclient/models/virtual_connection_item.py +6 -5
- tableauserverclient/models/webhook_item.py +39 -6
- tableauserverclient/models/workbook_item.py +116 -13
- tableauserverclient/namespace.py +1 -1
- tableauserverclient/server/__init__.py +2 -1
- tableauserverclient/server/endpoint/auth_endpoint.py +69 -10
- tableauserverclient/server/endpoint/custom_views_endpoint.py +258 -29
- tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py +2 -2
- tableauserverclient/server/endpoint/data_alert_endpoint.py +14 -14
- tableauserverclient/server/endpoint/databases_endpoint.py +13 -12
- tableauserverclient/server/endpoint/datasources_endpoint.py +61 -62
- tableauserverclient/server/endpoint/default_permissions_endpoint.py +19 -18
- tableauserverclient/server/endpoint/dqw_endpoint.py +9 -9
- tableauserverclient/server/endpoint/endpoint.py +19 -21
- tableauserverclient/server/endpoint/exceptions.py +23 -7
- tableauserverclient/server/endpoint/favorites_endpoint.py +31 -31
- tableauserverclient/server/endpoint/fileuploads_endpoint.py +9 -11
- tableauserverclient/server/endpoint/flow_runs_endpoint.py +15 -13
- tableauserverclient/server/endpoint/flow_task_endpoint.py +2 -2
- tableauserverclient/server/endpoint/flows_endpoint.py +344 -29
- tableauserverclient/server/endpoint/groups_endpoint.py +342 -27
- tableauserverclient/server/endpoint/groupsets_endpoint.py +2 -2
- tableauserverclient/server/endpoint/jobs_endpoint.py +116 -7
- tableauserverclient/server/endpoint/linked_tasks_endpoint.py +2 -2
- tableauserverclient/server/endpoint/metadata_endpoint.py +2 -2
- tableauserverclient/server/endpoint/metrics_endpoint.py +10 -10
- tableauserverclient/server/endpoint/permissions_endpoint.py +13 -15
- tableauserverclient/server/endpoint/projects_endpoint.py +681 -30
- tableauserverclient/server/endpoint/resource_tagger.py +14 -13
- tableauserverclient/server/endpoint/schedules_endpoint.py +17 -18
- tableauserverclient/server/endpoint/server_info_endpoint.py +40 -5
- tableauserverclient/server/endpoint/sites_endpoint.py +282 -17
- tableauserverclient/server/endpoint/subscriptions_endpoint.py +10 -10
- tableauserverclient/server/endpoint/tables_endpoint.py +15 -14
- tableauserverclient/server/endpoint/tasks_endpoint.py +86 -8
- tableauserverclient/server/endpoint/users_endpoint.py +366 -19
- tableauserverclient/server/endpoint/views_endpoint.py +262 -20
- tableauserverclient/server/endpoint/virtual_connections_endpoint.py +6 -5
- tableauserverclient/server/endpoint/webhooks_endpoint.py +88 -11
- tableauserverclient/server/endpoint/workbooks_endpoint.py +653 -65
- tableauserverclient/server/filter.py +2 -2
- tableauserverclient/server/pager.py +29 -6
- tableauserverclient/server/query.py +68 -19
- tableauserverclient/server/request_factory.py +57 -37
- tableauserverclient/server/request_options.py +243 -141
- tableauserverclient/server/server.py +76 -10
- tableauserverclient/server/sort.py +16 -2
- {tableauserverclient-0.33.dist-info → tableauserverclient-0.35.dist-info}/METADATA +7 -7
- tableauserverclient-0.35.dist-info/RECORD +106 -0
- {tableauserverclient-0.33.dist-info → tableauserverclient-0.35.dist-info}/WHEEL +1 -1
- tableauserverclient-0.33.dist-info/RECORD +0 -106
- {tableauserverclient-0.33.dist-info → tableauserverclient-0.35.dist-info}/LICENSE +0 -0
- {tableauserverclient-0.33.dist-info → tableauserverclient-0.35.dist-info}/LICENSE.versioneer +0 -0
- {tableauserverclient-0.33.dist-info → tableauserverclient-0.35.dist-info}/top_level.txt +0 -0
|
@@ -6,7 +6,8 @@ import os
|
|
|
6
6
|
|
|
7
7
|
from contextlib import closing
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from typing import
|
|
9
|
+
from typing import Optional, TYPE_CHECKING, Union
|
|
10
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
10
11
|
|
|
11
12
|
from tableauserverclient.helpers.headers import fix_filename
|
|
12
13
|
from tableauserverclient.server.query import QuerySet
|
|
@@ -22,7 +23,7 @@ from tableauserverclient.server.endpoint.exceptions import InternalServerError,
|
|
|
22
23
|
from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint
|
|
23
24
|
from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin
|
|
24
25
|
|
|
25
|
-
from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS,
|
|
26
|
+
from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, BYTES_PER_MB, config
|
|
26
27
|
from tableauserverclient.filesys_helpers import (
|
|
27
28
|
make_download_path,
|
|
28
29
|
get_file_type,
|
|
@@ -57,7 +58,7 @@ PathOrFileW = Union[FilePath, FileObjectW]
|
|
|
57
58
|
|
|
58
59
|
class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]):
|
|
59
60
|
def __init__(self, parent_srv: "Server") -> None:
|
|
60
|
-
super(
|
|
61
|
+
super().__init__(parent_srv)
|
|
61
62
|
self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl)
|
|
62
63
|
self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "datasource")
|
|
63
64
|
|
|
@@ -65,11 +66,11 @@ class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]
|
|
|
65
66
|
|
|
66
67
|
@property
|
|
67
68
|
def baseurl(self) -> str:
|
|
68
|
-
return "{
|
|
69
|
+
return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/datasources"
|
|
69
70
|
|
|
70
71
|
# Get all datasources
|
|
71
72
|
@api(version="2.0")
|
|
72
|
-
def get(self, req_options: Optional[RequestOptions] = None) ->
|
|
73
|
+
def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[DatasourceItem], PaginationItem]:
|
|
73
74
|
logger.info("Querying all datasources on site")
|
|
74
75
|
url = self.baseurl
|
|
75
76
|
server_response = self.get_request(url, req_options)
|
|
@@ -83,8 +84,8 @@ class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]
|
|
|
83
84
|
if not datasource_id:
|
|
84
85
|
error = "Datasource ID undefined."
|
|
85
86
|
raise ValueError(error)
|
|
86
|
-
logger.info("Querying single datasource (ID: {
|
|
87
|
-
url = "{
|
|
87
|
+
logger.info(f"Querying single datasource (ID: {datasource_id})")
|
|
88
|
+
url = f"{self.baseurl}/{datasource_id}"
|
|
88
89
|
server_response = self.get_request(url)
|
|
89
90
|
return DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0]
|
|
90
91
|
|
|
@@ -99,12 +100,17 @@ class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]
|
|
|
99
100
|
return self._get_datasource_connections(datasource_item)
|
|
100
101
|
|
|
101
102
|
datasource_item._set_connections(connections_fetcher)
|
|
102
|
-
logger.info("Populated connections for datasource (ID: {
|
|
103
|
+
logger.info(f"Populated connections for datasource (ID: {datasource_item.id})")
|
|
103
104
|
|
|
104
|
-
def _get_datasource_connections(
|
|
105
|
-
|
|
105
|
+
def _get_datasource_connections(
|
|
106
|
+
self, datasource_item: DatasourceItem, req_options: Optional[RequestOptions] = None
|
|
107
|
+
) -> list[ConnectionItem]:
|
|
108
|
+
url = f"{self.baseurl}/{datasource_item.id}/connections"
|
|
106
109
|
server_response = self.get_request(url, req_options)
|
|
107
110
|
connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)
|
|
111
|
+
for connection in connections:
|
|
112
|
+
connection._datasource_id = datasource_item.id
|
|
113
|
+
connection._datasource_name = datasource_item.name
|
|
108
114
|
return connections
|
|
109
115
|
|
|
110
116
|
# Delete 1 datasource by id
|
|
@@ -113,9 +119,9 @@ class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]
|
|
|
113
119
|
if not datasource_id:
|
|
114
120
|
error = "Datasource ID undefined."
|
|
115
121
|
raise ValueError(error)
|
|
116
|
-
url = "{
|
|
122
|
+
url = f"{self.baseurl}/{datasource_id}"
|
|
117
123
|
self.delete_request(url)
|
|
118
|
-
logger.info("Deleted single datasource (ID: {
|
|
124
|
+
logger.info(f"Deleted single datasource (ID: {datasource_id})")
|
|
119
125
|
|
|
120
126
|
# Download 1 datasource by id
|
|
121
127
|
@api(version="2.0")
|
|
@@ -152,11 +158,11 @@ class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]
|
|
|
152
158
|
self.update_tags(datasource_item)
|
|
153
159
|
|
|
154
160
|
# Update the datasource itself
|
|
155
|
-
url = "{
|
|
161
|
+
url = f"{self.baseurl}/{datasource_item.id}"
|
|
156
162
|
|
|
157
163
|
update_req = RequestFactory.Datasource.update_req(datasource_item)
|
|
158
164
|
server_response = self.put_request(url, update_req)
|
|
159
|
-
logger.info("Updated datasource item (ID: {
|
|
165
|
+
logger.info(f"Updated datasource item (ID: {datasource_item.id})")
|
|
160
166
|
updated_datasource = copy.copy(datasource_item)
|
|
161
167
|
return updated_datasource._parse_common_elements(server_response.content, self.parent_srv.namespace)
|
|
162
168
|
|
|
@@ -165,7 +171,7 @@ class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]
|
|
|
165
171
|
def update_connection(
|
|
166
172
|
self, datasource_item: DatasourceItem, connection_item: ConnectionItem
|
|
167
173
|
) -> Optional[ConnectionItem]:
|
|
168
|
-
url = "{
|
|
174
|
+
url = f"{self.baseurl}/{datasource_item.id}/connections/{connection_item.id}"
|
|
169
175
|
|
|
170
176
|
update_req = RequestFactory.Connection.update_req(connection_item)
|
|
171
177
|
server_response = self.put_request(url, update_req)
|
|
@@ -174,27 +180,25 @@ class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]
|
|
|
174
180
|
return None
|
|
175
181
|
|
|
176
182
|
if len(connections) > 1:
|
|
177
|
-
logger.debug("Multiple connections returned ({
|
|
183
|
+
logger.debug(f"Multiple connections returned ({len(connections)})")
|
|
178
184
|
connection = list(filter(lambda x: x.id == connection_item.id, connections))[0]
|
|
179
185
|
|
|
180
|
-
logger.info(
|
|
181
|
-
"Updated datasource item (ID: {0} & connection item {1}".format(datasource_item.id, connection_item.id)
|
|
182
|
-
)
|
|
186
|
+
logger.info(f"Updated datasource item (ID: {datasource_item.id} & connection item {connection_item.id}")
|
|
183
187
|
return connection
|
|
184
188
|
|
|
185
189
|
@api(version="2.8")
|
|
186
|
-
def refresh(self, datasource_item: DatasourceItem) -> JobItem:
|
|
190
|
+
def refresh(self, datasource_item: DatasourceItem, incremental: bool = False) -> JobItem:
|
|
187
191
|
id_ = getattr(datasource_item, "id", datasource_item)
|
|
188
|
-
url = "{
|
|
189
|
-
|
|
190
|
-
server_response = self.post_request(url,
|
|
192
|
+
url = f"{self.baseurl}/{id_}/refresh"
|
|
193
|
+
refresh_req = RequestFactory.Task.refresh_req(incremental)
|
|
194
|
+
server_response = self.post_request(url, refresh_req)
|
|
191
195
|
new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0]
|
|
192
196
|
return new_job
|
|
193
197
|
|
|
194
198
|
@api(version="3.5")
|
|
195
199
|
def create_extract(self, datasource_item: DatasourceItem, encrypt: bool = False) -> JobItem:
|
|
196
200
|
id_ = getattr(datasource_item, "id", datasource_item)
|
|
197
|
-
url = "{
|
|
201
|
+
url = f"{self.baseurl}/{id_}/createExtract?encrypt={encrypt}"
|
|
198
202
|
empty_req = RequestFactory.Empty.empty_req()
|
|
199
203
|
server_response = self.post_request(url, empty_req)
|
|
200
204
|
new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0]
|
|
@@ -203,7 +207,7 @@ class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]
|
|
|
203
207
|
@api(version="3.5")
|
|
204
208
|
def delete_extract(self, datasource_item: DatasourceItem) -> None:
|
|
205
209
|
id_ = getattr(datasource_item, "id", datasource_item)
|
|
206
|
-
url = "{
|
|
210
|
+
url = f"{self.baseurl}/{id_}/deleteExtract"
|
|
207
211
|
empty_req = RequestFactory.Empty.empty_req()
|
|
208
212
|
self.post_request(url, empty_req)
|
|
209
213
|
|
|
@@ -223,12 +227,12 @@ class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]
|
|
|
223
227
|
if isinstance(file, (os.PathLike, str)):
|
|
224
228
|
if not os.path.isfile(file):
|
|
225
229
|
error = "File path does not lead to an existing file."
|
|
226
|
-
raise
|
|
230
|
+
raise OSError(error)
|
|
227
231
|
|
|
228
232
|
filename = os.path.basename(file)
|
|
229
233
|
file_extension = os.path.splitext(filename)[1][1:]
|
|
230
234
|
file_size = os.path.getsize(file)
|
|
231
|
-
logger.debug("Publishing file `{}`, size `{}`"
|
|
235
|
+
logger.debug(f"Publishing file `{filename}`, size `{file_size}`")
|
|
232
236
|
# If name is not defined, grab the name from the file to publish
|
|
233
237
|
if not datasource_item.name:
|
|
234
238
|
datasource_item.name = os.path.splitext(filename)[0]
|
|
@@ -247,41 +251,40 @@ class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]
|
|
|
247
251
|
elif file_type == "xml":
|
|
248
252
|
file_extension = "tds"
|
|
249
253
|
else:
|
|
250
|
-
error = "Unsupported file type {}"
|
|
254
|
+
error = f"Unsupported file type {file_type}"
|
|
251
255
|
raise ValueError(error)
|
|
252
256
|
|
|
253
|
-
filename = "{}.{}"
|
|
257
|
+
filename = f"{datasource_item.name}.{file_extension}"
|
|
254
258
|
file_size = get_file_object_size(file)
|
|
255
259
|
|
|
256
260
|
else:
|
|
257
261
|
raise TypeError("file should be a filepath or file object.")
|
|
258
262
|
|
|
263
|
+
# Construct the url with the defined mode
|
|
264
|
+
url = f"{self.baseurl}?datasourceType={file_extension}"
|
|
259
265
|
if not mode or not hasattr(self.parent_srv.PublishMode, mode):
|
|
260
|
-
error = "Invalid mode defined
|
|
266
|
+
error = f"Invalid mode defined: {mode}"
|
|
261
267
|
raise ValueError(error)
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
url = "{0}?datasourceType={1}".format(self.baseurl, file_extension)
|
|
265
|
-
if mode == self.parent_srv.PublishMode.Overwrite or mode == self.parent_srv.PublishMode.Append:
|
|
266
|
-
url += "&{0}=true".format(mode.lower())
|
|
268
|
+
else:
|
|
269
|
+
url += f"&{mode.lower()}=true"
|
|
267
270
|
|
|
268
271
|
if as_job:
|
|
269
|
-
url += "&{
|
|
272
|
+
url += "&{}=true".format("asJob")
|
|
270
273
|
|
|
271
274
|
# Determine if chunking is required (64MB is the limit for single upload method)
|
|
272
|
-
if file_size >= FILESIZE_LIMIT_MB * BYTES_PER_MB:
|
|
275
|
+
if file_size >= config.FILESIZE_LIMIT_MB * BYTES_PER_MB:
|
|
273
276
|
logger.info(
|
|
274
277
|
"Publishing {} to server with chunking method (datasource over {}MB, chunk size {}MB)".format(
|
|
275
|
-
filename, FILESIZE_LIMIT_MB, config.CHUNK_SIZE_MB
|
|
278
|
+
filename, config.FILESIZE_LIMIT_MB, config.CHUNK_SIZE_MB
|
|
276
279
|
)
|
|
277
280
|
)
|
|
278
281
|
upload_session_id = self.parent_srv.fileuploads.upload(file)
|
|
279
|
-
url = "{
|
|
282
|
+
url = f"{url}&uploadSessionId={upload_session_id}"
|
|
280
283
|
xml_request, content_type = RequestFactory.Datasource.publish_req_chunked(
|
|
281
284
|
datasource_item, connection_credentials, connections
|
|
282
285
|
)
|
|
283
286
|
else:
|
|
284
|
-
logger.info("Publishing {
|
|
287
|
+
logger.info(f"Publishing {filename} to server")
|
|
285
288
|
|
|
286
289
|
if isinstance(file, (Path, str)):
|
|
287
290
|
with open(file, "rb") as f:
|
|
@@ -309,11 +312,11 @@ class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]
|
|
|
309
312
|
|
|
310
313
|
if as_job:
|
|
311
314
|
new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0]
|
|
312
|
-
logger.info("Published {
|
|
315
|
+
logger.info(f"Published {filename} (JOB_ID: {new_job.id}")
|
|
313
316
|
return new_job
|
|
314
317
|
else:
|
|
315
318
|
new_datasource = DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0]
|
|
316
|
-
logger.info("Published {
|
|
319
|
+
logger.info(f"Published {filename} (ID: {new_datasource.id})")
|
|
317
320
|
return new_datasource
|
|
318
321
|
|
|
319
322
|
@api(version="3.13")
|
|
@@ -327,23 +330,23 @@ class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]
|
|
|
327
330
|
) -> JobItem:
|
|
328
331
|
if isinstance(datasource_or_connection_item, DatasourceItem):
|
|
329
332
|
datasource_id = datasource_or_connection_item.id
|
|
330
|
-
url = "{
|
|
333
|
+
url = f"{self.baseurl}/{datasource_id}/data"
|
|
331
334
|
elif isinstance(datasource_or_connection_item, ConnectionItem):
|
|
332
335
|
datasource_id = datasource_or_connection_item.datasource_id
|
|
333
336
|
connection_id = datasource_or_connection_item.id
|
|
334
|
-
url = "{
|
|
337
|
+
url = f"{self.baseurl}/{datasource_id}/connections/{connection_id}/data"
|
|
335
338
|
else:
|
|
336
339
|
assert isinstance(datasource_or_connection_item, str)
|
|
337
|
-
url = "{
|
|
340
|
+
url = f"{self.baseurl}/{datasource_or_connection_item}/data"
|
|
338
341
|
|
|
339
342
|
if payload is not None:
|
|
340
343
|
if not os.path.isfile(payload):
|
|
341
344
|
error = "File path does not lead to an existing file."
|
|
342
|
-
raise
|
|
345
|
+
raise OSError(error)
|
|
343
346
|
|
|
344
|
-
logger.info("Uploading {
|
|
347
|
+
logger.info(f"Uploading {payload} to server with chunking method for Update job")
|
|
345
348
|
upload_session_id = self.parent_srv.fileuploads.upload(payload)
|
|
346
|
-
url = "{
|
|
349
|
+
url = f"{url}?uploadSessionId={upload_session_id}"
|
|
347
350
|
|
|
348
351
|
json_request = json.dumps({"actions": actions})
|
|
349
352
|
parameters = {"headers": {"requestid": request_id}}
|
|
@@ -356,7 +359,7 @@ class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]
|
|
|
356
359
|
self._permissions.populate(item)
|
|
357
360
|
|
|
358
361
|
@api(version="2.0")
|
|
359
|
-
def update_permissions(self, item: DatasourceItem, permission_item:
|
|
362
|
+
def update_permissions(self, item: DatasourceItem, permission_item: list["PermissionsRule"]) -> None:
|
|
360
363
|
self._permissions.update(item, permission_item)
|
|
361
364
|
|
|
362
365
|
@api(version="2.0")
|
|
@@ -390,12 +393,12 @@ class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]
|
|
|
390
393
|
return self._get_datasource_revisions(datasource_item)
|
|
391
394
|
|
|
392
395
|
datasource_item._set_revisions(revisions_fetcher)
|
|
393
|
-
logger.info("Populated revisions for datasource (ID: {
|
|
396
|
+
logger.info(f"Populated revisions for datasource (ID: {datasource_item.id})")
|
|
394
397
|
|
|
395
398
|
def _get_datasource_revisions(
|
|
396
399
|
self, datasource_item: DatasourceItem, req_options: Optional["RequestOptions"] = None
|
|
397
|
-
) ->
|
|
398
|
-
url = "{
|
|
400
|
+
) -> list[RevisionItem]:
|
|
401
|
+
url = f"{self.baseurl}/{datasource_item.id}/revisions"
|
|
399
402
|
server_response = self.get_request(url, req_options)
|
|
400
403
|
revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, datasource_item)
|
|
401
404
|
return revisions
|
|
@@ -413,9 +416,9 @@ class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]
|
|
|
413
416
|
error = "Datasource ID undefined."
|
|
414
417
|
raise ValueError(error)
|
|
415
418
|
if revision_number is None:
|
|
416
|
-
url = "{
|
|
419
|
+
url = f"{self.baseurl}/{datasource_id}/content"
|
|
417
420
|
else:
|
|
418
|
-
url = "{
|
|
421
|
+
url = f"{self.baseurl}/{datasource_id}/revisions/{revision_number}/content"
|
|
419
422
|
|
|
420
423
|
if not include_extract:
|
|
421
424
|
url += "?includeExtract=False"
|
|
@@ -437,9 +440,7 @@ class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]
|
|
|
437
440
|
f.write(chunk)
|
|
438
441
|
return_path = os.path.abspath(download_path)
|
|
439
442
|
|
|
440
|
-
logger.info(
|
|
441
|
-
"Downloaded datasource revision {0} to {1} (ID: {2})".format(revision_number, return_path, datasource_id)
|
|
442
|
-
)
|
|
443
|
+
logger.info(f"Downloaded datasource revision {revision_number} to {return_path} (ID: {datasource_id})")
|
|
443
444
|
return return_path
|
|
444
445
|
|
|
445
446
|
@api(version="2.3")
|
|
@@ -449,19 +450,17 @@ class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]
|
|
|
449
450
|
url = "/".join([self.baseurl, datasource_id, "revisions", revision_number])
|
|
450
451
|
|
|
451
452
|
self.delete_request(url)
|
|
452
|
-
logger.info(
|
|
453
|
-
"Deleted single datasource revision (ID: {0}) (Revision: {1})".format(datasource_id, revision_number)
|
|
454
|
-
)
|
|
453
|
+
logger.info(f"Deleted single datasource revision (ID: {datasource_id}) (Revision: {revision_number})")
|
|
455
454
|
|
|
456
455
|
# a convenience method
|
|
457
456
|
@api(version="2.8")
|
|
458
457
|
def schedule_extract_refresh(
|
|
459
458
|
self, schedule_id: str, item: DatasourceItem
|
|
460
|
-
) ->
|
|
459
|
+
) -> list["AddResponse"]: # actually should return a task
|
|
461
460
|
return self.parent_srv.schedules.add_to_schedule(schedule_id, datasource=item)
|
|
462
461
|
|
|
463
462
|
@api(version="1.0")
|
|
464
|
-
def add_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) ->
|
|
463
|
+
def add_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> set[str]:
|
|
465
464
|
return super().add_tags(item, tags)
|
|
466
465
|
|
|
467
466
|
@api(version="1.0")
|
|
@@ -4,7 +4,8 @@ from .endpoint import Endpoint
|
|
|
4
4
|
from .exceptions import MissingRequiredFieldError
|
|
5
5
|
from tableauserverclient.server import RequestFactory
|
|
6
6
|
from tableauserverclient.models import DatabaseItem, PermissionsRule, ProjectItem, plural_type, Resource
|
|
7
|
-
from typing import TYPE_CHECKING, Callable,
|
|
7
|
+
from typing import TYPE_CHECKING, Callable, Optional, Union
|
|
8
|
+
from collections.abc import Sequence
|
|
8
9
|
|
|
9
10
|
if TYPE_CHECKING:
|
|
10
11
|
from ..server import Server
|
|
@@ -25,7 +26,7 @@ class _DefaultPermissionsEndpoint(Endpoint):
|
|
|
25
26
|
"""
|
|
26
27
|
|
|
27
28
|
def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> None:
|
|
28
|
-
super(
|
|
29
|
+
super().__init__(parent_srv)
|
|
29
30
|
|
|
30
31
|
# owner_baseurl is the baseurl of the parent, a project or database.
|
|
31
32
|
# It MUST be a lambda since we don't know the full site URL until we sign in.
|
|
@@ -33,23 +34,25 @@ class _DefaultPermissionsEndpoint(Endpoint):
|
|
|
33
34
|
self.owner_baseurl = owner_baseurl
|
|
34
35
|
|
|
35
36
|
def __str__(self):
|
|
36
|
-
return "<DefaultPermissionsEndpoint {} [Flow, Datasource, Workbook, Lens]>"
|
|
37
|
+
return f"<DefaultPermissionsEndpoint {self.owner_baseurl()} [Flow, Datasource, Workbook, Lens]>"
|
|
37
38
|
|
|
38
39
|
__repr__ = __str__
|
|
39
40
|
|
|
40
41
|
def update_default_permissions(
|
|
41
|
-
self, resource: BaseItem, permissions: Sequence[PermissionsRule], content_type: Resource
|
|
42
|
-
) ->
|
|
43
|
-
url = "{
|
|
42
|
+
self, resource: BaseItem, permissions: Sequence[PermissionsRule], content_type: Union[Resource, str]
|
|
43
|
+
) -> list[PermissionsRule]:
|
|
44
|
+
url = f"{self.owner_baseurl()}/{resource.id}/default-permissions/{plural_type(content_type)}"
|
|
44
45
|
update_req = RequestFactory.Permission.add_req(permissions)
|
|
45
46
|
response = self.put_request(url, update_req)
|
|
46
47
|
permissions = PermissionsRule.from_response(response.content, self.parent_srv.namespace)
|
|
47
|
-
logger.info("Updated default {} permissions for resource {
|
|
48
|
+
logger.info(f"Updated default {content_type} permissions for resource {resource.id}")
|
|
48
49
|
logger.info(permissions)
|
|
49
50
|
|
|
50
51
|
return permissions
|
|
51
52
|
|
|
52
|
-
def delete_default_permission(
|
|
53
|
+
def delete_default_permission(
|
|
54
|
+
self, resource: BaseItem, rule: PermissionsRule, content_type: Union[Resource, str]
|
|
55
|
+
) -> None:
|
|
53
56
|
for capability, mode in rule.capabilities.items():
|
|
54
57
|
# Made readability better but line is too long, will make this look better
|
|
55
58
|
url = (
|
|
@@ -65,29 +68,27 @@ class _DefaultPermissionsEndpoint(Endpoint):
|
|
|
65
68
|
)
|
|
66
69
|
)
|
|
67
70
|
|
|
68
|
-
logger.debug("Removing {
|
|
71
|
+
logger.debug(f"Removing {mode} permission for capability {capability}")
|
|
69
72
|
|
|
70
73
|
self.delete_request(url)
|
|
71
74
|
|
|
72
|
-
logger.info(
|
|
73
|
-
"Deleted permission for {0} {1} item {2}".format(rule.grantee.tag_name, rule.grantee.id, resource.id)
|
|
74
|
-
)
|
|
75
|
+
logger.info(f"Deleted permission for {rule.grantee.tag_name} {rule.grantee.id} item {resource.id}")
|
|
75
76
|
|
|
76
|
-
def populate_default_permissions(self, item: BaseItem, content_type: Resource) -> None:
|
|
77
|
+
def populate_default_permissions(self, item: BaseItem, content_type: Union[Resource, str]) -> None:
|
|
77
78
|
if not item.id:
|
|
78
79
|
error = "Server item is missing ID. Item must be retrieved from server first."
|
|
79
80
|
raise MissingRequiredFieldError(error)
|
|
80
81
|
|
|
81
|
-
def permission_fetcher() ->
|
|
82
|
+
def permission_fetcher() -> list[PermissionsRule]:
|
|
82
83
|
return self._get_default_permissions(item, content_type)
|
|
83
84
|
|
|
84
85
|
item._set_default_permissions(permission_fetcher, content_type)
|
|
85
|
-
logger.info("Populated default {
|
|
86
|
+
logger.info(f"Populated default {content_type} permissions for item (ID: {item.id})")
|
|
86
87
|
|
|
87
88
|
def _get_default_permissions(
|
|
88
|
-
self, item: BaseItem, content_type: Resource, req_options: Optional["RequestOptions"] = None
|
|
89
|
-
) ->
|
|
90
|
-
url = "{
|
|
89
|
+
self, item: BaseItem, content_type: Union[Resource, str], req_options: Optional["RequestOptions"] = None
|
|
90
|
+
) -> list[PermissionsRule]:
|
|
91
|
+
url = f"{self.owner_baseurl()}/{item.id}/default-permissions/{plural_type(content_type)}"
|
|
91
92
|
server_response = self.get_request(url, req_options)
|
|
92
93
|
permissions = PermissionsRule.from_response(server_response.content, self.parent_srv.namespace)
|
|
93
94
|
logger.info({"content_type": content_type, "permissions": permissions})
|
|
@@ -10,35 +10,35 @@ from tableauserverclient.helpers.logging import logger
|
|
|
10
10
|
|
|
11
11
|
class _DataQualityWarningEndpoint(Endpoint):
|
|
12
12
|
def __init__(self, parent_srv, resource_type):
|
|
13
|
-
super(
|
|
13
|
+
super().__init__(parent_srv)
|
|
14
14
|
self.resource_type = resource_type
|
|
15
15
|
|
|
16
16
|
@property
|
|
17
17
|
def baseurl(self):
|
|
18
|
-
return "{
|
|
18
|
+
return "{}/sites/{}/dataQualityWarnings/{}".format(
|
|
19
19
|
self.parent_srv.baseurl, self.parent_srv.site_id, self.resource_type
|
|
20
20
|
)
|
|
21
21
|
|
|
22
22
|
def add(self, resource, warning):
|
|
23
|
-
url = "{baseurl}/{
|
|
23
|
+
url = f"{self.baseurl}/{resource.id}"
|
|
24
24
|
add_req = RequestFactory.DQW.add_req(warning)
|
|
25
25
|
response = self.post_request(url, add_req)
|
|
26
26
|
warnings = DQWItem.from_response(response.content, self.parent_srv.namespace)
|
|
27
|
-
logger.info("Added dqw for resource {
|
|
27
|
+
logger.info(f"Added dqw for resource {resource.id}")
|
|
28
28
|
|
|
29
29
|
return warnings
|
|
30
30
|
|
|
31
31
|
def update(self, resource, warning):
|
|
32
|
-
url = "{baseurl}/{
|
|
32
|
+
url = f"{self.baseurl}/{resource.id}"
|
|
33
33
|
add_req = RequestFactory.DQW.update_req(warning)
|
|
34
34
|
response = self.put_request(url, add_req)
|
|
35
35
|
warnings = DQWItem.from_response(response.content, self.parent_srv.namespace)
|
|
36
|
-
logger.info("Added dqw for resource {
|
|
36
|
+
logger.info(f"Added dqw for resource {resource.id}")
|
|
37
37
|
|
|
38
38
|
return warnings
|
|
39
39
|
|
|
40
40
|
def clear(self, resource):
|
|
41
|
-
url = "{baseurl}/{
|
|
41
|
+
url = f"{self.baseurl}/{resource.id}"
|
|
42
42
|
return self.delete_request(url)
|
|
43
43
|
|
|
44
44
|
def populate(self, item):
|
|
@@ -50,10 +50,10 @@ class _DataQualityWarningEndpoint(Endpoint):
|
|
|
50
50
|
return self._get_data_quality_warnings(item)
|
|
51
51
|
|
|
52
52
|
item._set_data_quality_warnings(dqw_fetcher)
|
|
53
|
-
logger.info("Populated permissions for item (ID: {
|
|
53
|
+
logger.info(f"Populated permissions for item (ID: {item.id})")
|
|
54
54
|
|
|
55
55
|
def _get_data_quality_warnings(self, item, req_options=None):
|
|
56
|
-
url = "{baseurl}/{
|
|
56
|
+
url = f"{self.baseurl}/{item.id}"
|
|
57
57
|
server_response = self.get_request(url, req_options)
|
|
58
58
|
dqws = DQWItem.from_response(server_response.content, self.parent_srv.namespace)
|
|
59
59
|
|
|
@@ -8,12 +8,9 @@ from xml.etree.ElementTree import ParseError
|
|
|
8
8
|
from typing import (
|
|
9
9
|
Any,
|
|
10
10
|
Callable,
|
|
11
|
-
Dict,
|
|
12
11
|
Generic,
|
|
13
|
-
List,
|
|
14
12
|
Optional,
|
|
15
13
|
TYPE_CHECKING,
|
|
16
|
-
Tuple,
|
|
17
14
|
TypeVar,
|
|
18
15
|
Union,
|
|
19
16
|
)
|
|
@@ -22,6 +19,7 @@ from tableauserverclient.models.pagination_item import PaginationItem
|
|
|
22
19
|
from tableauserverclient.server.request_options import RequestOptions
|
|
23
20
|
|
|
24
21
|
from tableauserverclient.server.endpoint.exceptions import (
|
|
22
|
+
FailedSignInError,
|
|
25
23
|
ServerResponseError,
|
|
26
24
|
InternalServerError,
|
|
27
25
|
NonXMLResponseError,
|
|
@@ -56,7 +54,7 @@ class Endpoint:
|
|
|
56
54
|
async_response = None
|
|
57
55
|
|
|
58
56
|
@staticmethod
|
|
59
|
-
def set_parameters(http_options, auth_token, content, content_type, parameters) ->
|
|
57
|
+
def set_parameters(http_options, auth_token, content, content_type, parameters) -> dict[str, Any]:
|
|
60
58
|
parameters = parameters or {}
|
|
61
59
|
parameters.update(http_options)
|
|
62
60
|
if "headers" not in parameters:
|
|
@@ -82,7 +80,7 @@ class Endpoint:
|
|
|
82
80
|
else:
|
|
83
81
|
# only set the TSC user agent if not already populated
|
|
84
82
|
_client_version: Optional[str] = get_versions()["version"]
|
|
85
|
-
parameters["headers"][USER_AGENT_HEADER] = "Tableau Server Client/{}"
|
|
83
|
+
parameters["headers"][USER_AGENT_HEADER] = f"Tableau Server Client/{_client_version}"
|
|
86
84
|
|
|
87
85
|
# result: parameters["headers"]["User-Agent"] is set
|
|
88
86
|
# return explicitly for testing only
|
|
@@ -90,12 +88,12 @@ class Endpoint:
|
|
|
90
88
|
|
|
91
89
|
def _blocking_request(self, method, url, parameters={}) -> Optional[Union["Response", Exception]]:
|
|
92
90
|
response = None
|
|
93
|
-
logger.debug("[{}] Begin blocking request to {}"
|
|
91
|
+
logger.debug(f"[{datetime.timestamp()}] Begin blocking request to {url}")
|
|
94
92
|
try:
|
|
95
93
|
response = method(url, **parameters)
|
|
96
|
-
logger.debug("[{}] Call finished"
|
|
94
|
+
logger.debug(f"[{datetime.timestamp()}] Call finished")
|
|
97
95
|
except Exception as e:
|
|
98
|
-
logger.debug("Error making request to server: {}"
|
|
96
|
+
logger.debug(f"Error making request to server: {e}")
|
|
99
97
|
raise e
|
|
100
98
|
return response
|
|
101
99
|
|
|
@@ -111,13 +109,13 @@ class Endpoint:
|
|
|
111
109
|
content: Optional[bytes] = None,
|
|
112
110
|
auth_token: Optional[str] = None,
|
|
113
111
|
content_type: Optional[str] = None,
|
|
114
|
-
parameters: Optional[
|
|
112
|
+
parameters: Optional[dict[str, Any]] = None,
|
|
115
113
|
) -> "Response":
|
|
116
114
|
parameters = Endpoint.set_parameters(
|
|
117
115
|
self.parent_srv.http_options, auth_token, content, content_type, parameters
|
|
118
116
|
)
|
|
119
117
|
|
|
120
|
-
logger.debug("request method {}, url: {}"
|
|
118
|
+
logger.debug(f"request method {method.__name__}, url: {url}")
|
|
121
119
|
if content:
|
|
122
120
|
redacted = helpers.strings.redact_xml(content[:200])
|
|
123
121
|
# this needs to be under a trace or something, it's a LOT
|
|
@@ -129,21 +127,21 @@ class Endpoint:
|
|
|
129
127
|
server_response: Optional[Union["Response", Exception]] = self.send_request_while_show_progress_threaded(
|
|
130
128
|
method, url, parameters, request_timeout
|
|
131
129
|
)
|
|
132
|
-
logger.debug("[{}] Async request returned: received {}"
|
|
130
|
+
logger.debug(f"[{datetime.timestamp()}] Async request returned: received {server_response}")
|
|
133
131
|
# is this blocking retry really necessary? I guess if it was just the threading messing it up?
|
|
134
132
|
if server_response is None:
|
|
135
133
|
logger.debug(server_response)
|
|
136
|
-
logger.debug("[{}] Async request failed: retrying"
|
|
134
|
+
logger.debug(f"[{datetime.timestamp()}] Async request failed: retrying")
|
|
137
135
|
server_response = self._blocking_request(method, url, parameters)
|
|
138
136
|
if server_response is None:
|
|
139
|
-
logger.debug("[{}] Request failed"
|
|
137
|
+
logger.debug(f"[{datetime.timestamp()}] Request failed")
|
|
140
138
|
raise RuntimeError
|
|
141
139
|
if isinstance(server_response, Exception):
|
|
142
140
|
raise server_response
|
|
143
141
|
self._check_status(server_response, url)
|
|
144
142
|
|
|
145
143
|
loggable_response = self.log_response_safely(server_response)
|
|
146
|
-
logger.debug("Server response from {
|
|
144
|
+
logger.debug(f"Server response from {url}")
|
|
147
145
|
# uncomment the following to log full responses in debug mode
|
|
148
146
|
# BE CAREFUL WHEN SHARING THESE RESULTS - MAY CONTAIN YOUR SENSITIVE DATA
|
|
149
147
|
# logger.debug(loggable_response)
|
|
@@ -154,16 +152,16 @@ class Endpoint:
|
|
|
154
152
|
return server_response
|
|
155
153
|
|
|
156
154
|
def _check_status(self, server_response: "Response", url: Optional[str] = None):
|
|
157
|
-
logger.debug("Response status: {}"
|
|
155
|
+
logger.debug(f"Response status: {server_response}")
|
|
158
156
|
if not hasattr(server_response, "status_code"):
|
|
159
|
-
raise
|
|
157
|
+
raise OSError("Response is not a http response?")
|
|
160
158
|
if server_response.status_code >= 500:
|
|
161
159
|
raise InternalServerError(server_response, url)
|
|
162
160
|
elif server_response.status_code not in Success_codes:
|
|
163
161
|
try:
|
|
164
162
|
if server_response.status_code == 401:
|
|
165
163
|
# TODO: catch this in server.py and attempt to sign in again, in case it's a session expiry
|
|
166
|
-
raise
|
|
164
|
+
raise FailedSignInError.from_response(server_response.content, self.parent_srv.namespace, url)
|
|
167
165
|
|
|
168
166
|
raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace, url)
|
|
169
167
|
except ParseError:
|
|
@@ -183,9 +181,9 @@ class Endpoint:
|
|
|
183
181
|
# content-type is an octet-stream accomplishes the same goal without eagerly loading content.
|
|
184
182
|
# This check is to determine if the response is a text response (xml or otherwise)
|
|
185
183
|
# so that we do not attempt to log bytes and other binary data.
|
|
186
|
-
loggable_response = "Content type `{}`"
|
|
184
|
+
loggable_response = f"Content type `{content_type}`"
|
|
187
185
|
if content_type == "application/octet-stream":
|
|
188
|
-
loggable_response = "A stream of type {} [Truncated File Contents]"
|
|
186
|
+
loggable_response = f"A stream of type {content_type} [Truncated File Contents]"
|
|
189
187
|
elif server_response.encoding and len(server_response.content) > 0:
|
|
190
188
|
loggable_response = helpers.strings.redact_xml(server_response.content.decode(server_response.encoding))
|
|
191
189
|
return loggable_response
|
|
@@ -313,7 +311,7 @@ def parameter_added_in(**params: str) -> Callable[[Callable[Concatenate[E, P], R
|
|
|
313
311
|
for p in params_to_check:
|
|
314
312
|
min_ver = Version(str(params[p]))
|
|
315
313
|
if server_ver < min_ver:
|
|
316
|
-
error = "{!r} not available in {}, it will be ignored. Added in {}"
|
|
314
|
+
error = f"{p!r} not available in {server_ver}, it will be ignored. Added in {min_ver}"
|
|
317
315
|
warnings.warn(error)
|
|
318
316
|
return func(self, *args, **kwargs)
|
|
319
317
|
|
|
@@ -353,5 +351,5 @@ class QuerysetEndpoint(Endpoint, Generic[T]):
|
|
|
353
351
|
return queryset
|
|
354
352
|
|
|
355
353
|
@abc.abstractmethod
|
|
356
|
-
def get(self, request_options: Optional[RequestOptions] = None) ->
|
|
354
|
+
def get(self, request_options: Optional[RequestOptions] = None) -> tuple[list[T], PaginationItem]:
|
|
357
355
|
raise NotImplementedError(f".get has not been implemented for {self.__class__.__qualname__}")
|