tableauserverclient 0.25__py3-none-any.whl → 0.27.post0.dev1__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 +42 -1
- tableauserverclient/_version.py +4 -4
- tableauserverclient/config.py +13 -0
- tableauserverclient/datetime_helpers.py +4 -0
- tableauserverclient/helpers/logging.py +4 -0
- tableauserverclient/models/__init__.py +1 -1
- tableauserverclient/models/column_item.py +3 -0
- tableauserverclient/models/connection_credentials.py +7 -0
- tableauserverclient/models/connection_item.py +1 -1
- tableauserverclient/models/custom_view_item.py +5 -0
- tableauserverclient/models/data_acceleration_report_item.py +3 -0
- tableauserverclient/models/datasource_item.py +10 -54
- tableauserverclient/models/favorites_item.py +56 -40
- tableauserverclient/models/fileupload_item.py +2 -2
- tableauserverclient/models/flow_item.py +30 -25
- tableauserverclient/models/group_item.py +1 -4
- tableauserverclient/models/interval_item.py +12 -0
- tableauserverclient/models/job_item.py +10 -1
- tableauserverclient/models/metric_item.py +36 -29
- tableauserverclient/models/pagination_item.py +3 -0
- tableauserverclient/models/permissions_item.py +8 -5
- tableauserverclient/models/project_item.py +11 -13
- tableauserverclient/models/schedule_item.py +6 -7
- tableauserverclient/models/server_info_item.py +2 -2
- tableauserverclient/models/site_item.py +3 -0
- tableauserverclient/models/subscription_item.py +8 -0
- tableauserverclient/models/table_item.py +6 -0
- tableauserverclient/models/tableau_auth.py +41 -6
- tableauserverclient/models/tableau_types.py +4 -2
- tableauserverclient/models/user_item.py +5 -1
- tableauserverclient/models/view_item.py +39 -36
- tableauserverclient/models/workbook_item.py +14 -43
- tableauserverclient/server/__init__.py +1 -3
- tableauserverclient/server/endpoint/__init__.py +1 -5
- tableauserverclient/server/endpoint/auth_endpoint.py +29 -8
- tableauserverclient/server/endpoint/custom_views_endpoint.py +1 -1
- tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py +1 -1
- tableauserverclient/server/endpoint/data_alert_endpoint.py +1 -1
- tableauserverclient/server/endpoint/databases_endpoint.py +1 -1
- tableauserverclient/server/endpoint/datasources_endpoint.py +21 -15
- tableauserverclient/server/endpoint/default_permissions_endpoint.py +1 -1
- tableauserverclient/server/endpoint/dqw_endpoint.py +1 -1
- tableauserverclient/server/endpoint/endpoint.py +98 -11
- tableauserverclient/server/endpoint/exceptions.py +1 -5
- tableauserverclient/server/endpoint/favorites_endpoint.py +71 -29
- tableauserverclient/server/endpoint/fileuploads_endpoint.py +11 -10
- tableauserverclient/server/endpoint/flow_runs_endpoint.py +1 -1
- tableauserverclient/server/endpoint/flows_endpoint.py +5 -5
- tableauserverclient/server/endpoint/groups_endpoint.py +5 -2
- tableauserverclient/server/endpoint/jobs_endpoint.py +1 -1
- tableauserverclient/server/endpoint/metadata_endpoint.py +1 -1
- tableauserverclient/server/endpoint/metrics_endpoint.py +1 -1
- tableauserverclient/server/endpoint/permissions_endpoint.py +1 -1
- tableauserverclient/server/endpoint/projects_endpoint.py +3 -1
- tableauserverclient/server/endpoint/resource_tagger.py +3 -3
- tableauserverclient/server/endpoint/schedules_endpoint.py +2 -1
- tableauserverclient/server/endpoint/server_info_endpoint.py +2 -4
- tableauserverclient/server/endpoint/sites_endpoint.py +1 -1
- tableauserverclient/server/endpoint/subscriptions_endpoint.py +1 -1
- tableauserverclient/server/endpoint/tables_endpoint.py +1 -1
- tableauserverclient/server/endpoint/tasks_endpoint.py +12 -1
- tableauserverclient/server/endpoint/users_endpoint.py +1 -1
- tableauserverclient/server/endpoint/views_endpoint.py +1 -1
- tableauserverclient/server/endpoint/webhooks_endpoint.py +1 -1
- tableauserverclient/server/endpoint/workbooks_endpoint.py +4 -2
- tableauserverclient/server/exceptions.py +8 -1
- tableauserverclient/server/filter.py +5 -1
- tableauserverclient/server/request_factory.py +56 -12
- tableauserverclient/server/request_options.py +4 -2
- tableauserverclient/server/server.py +12 -13
- {tableauserverclient-0.25.dist-info → tableauserverclient-0.27.post0.dev1.dist-info}/METADATA +12 -10
- tableauserverclient-0.27.post0.dev1.dist-info/RECORD +97 -0
- {tableauserverclient-0.25.dist-info → tableauserverclient-0.27.post0.dev1.dist-info}/WHEEL +1 -1
- tableauserverclient-0.25.dist-info/RECORD +0 -95
- {tableauserverclient-0.25.dist-info → tableauserverclient-0.27.post0.dev1.dist-info}/LICENSE +0 -0
- {tableauserverclient-0.25.dist-info → tableauserverclient-0.27.post0.dev1.dist-info}/LICENSE.versioneer +0 -0
- {tableauserverclient-0.25.dist-info → tableauserverclient-0.27.post0.dev1.dist-info}/top_level.txt +0 -0
|
@@ -20,7 +20,7 @@ from .view_item import ViewItem
|
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
class WorkbookItem(object):
|
|
23
|
-
def __init__(self, project_id: str, name: Optional[str] = None, show_tabs: bool = False) -> None:
|
|
23
|
+
def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, show_tabs: bool = False) -> None:
|
|
24
24
|
self._connections = None
|
|
25
25
|
self._content_url = None
|
|
26
26
|
self._webpage_url = None
|
|
@@ -38,7 +38,8 @@ class WorkbookItem(object):
|
|
|
38
38
|
self.name = name
|
|
39
39
|
self._description = None
|
|
40
40
|
self.owner_id: Optional[str] = None
|
|
41
|
-
|
|
41
|
+
# workaround for Personal Space workbooks without a project
|
|
42
|
+
self.project_id: Optional[str] = project_id or uuid.uuid4().__str__()
|
|
42
43
|
self.show_tabs = show_tabs
|
|
43
44
|
self.hidden_views: Optional[List[str]] = None
|
|
44
45
|
self.tags: Set[str] = set()
|
|
@@ -52,11 +53,14 @@ class WorkbookItem(object):
|
|
|
52
53
|
|
|
53
54
|
return None
|
|
54
55
|
|
|
55
|
-
def
|
|
56
|
+
def __str__(self):
|
|
56
57
|
return "<WorkbookItem {0} '{1}' contentUrl='{2}' project={3}>".format(
|
|
57
58
|
self._id, self.name, self.content_url, self.project_id
|
|
58
59
|
)
|
|
59
60
|
|
|
61
|
+
def __repr__(self):
|
|
62
|
+
return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}"
|
|
63
|
+
|
|
60
64
|
@property
|
|
61
65
|
def connections(self) -> List[ConnectionItem]:
|
|
62
66
|
if self._connections is None:
|
|
@@ -293,49 +297,16 @@ class WorkbookItem(object):
|
|
|
293
297
|
parsed_response = fromstring(resp)
|
|
294
298
|
all_workbook_xml = parsed_response.findall(".//t:workbook", namespaces=ns)
|
|
295
299
|
for workbook_xml in all_workbook_xml:
|
|
296
|
-
(
|
|
297
|
-
id,
|
|
298
|
-
name,
|
|
299
|
-
content_url,
|
|
300
|
-
webpage_url,
|
|
301
|
-
created_at,
|
|
302
|
-
description,
|
|
303
|
-
updated_at,
|
|
304
|
-
size,
|
|
305
|
-
show_tabs,
|
|
306
|
-
project_id,
|
|
307
|
-
project_name,
|
|
308
|
-
owner_id,
|
|
309
|
-
tags,
|
|
310
|
-
views,
|
|
311
|
-
data_acceleration_config,
|
|
312
|
-
) = cls._parse_element(workbook_xml, ns)
|
|
313
|
-
|
|
314
|
-
# workaround for Personal Space workbooks which won't have a project
|
|
315
|
-
if not project_id:
|
|
316
|
-
project_id = uuid.uuid4()
|
|
317
|
-
|
|
318
|
-
workbook_item = cls(project_id)
|
|
319
|
-
workbook_item._set_values(
|
|
320
|
-
id,
|
|
321
|
-
name,
|
|
322
|
-
content_url,
|
|
323
|
-
webpage_url,
|
|
324
|
-
created_at,
|
|
325
|
-
description,
|
|
326
|
-
updated_at,
|
|
327
|
-
size,
|
|
328
|
-
show_tabs,
|
|
329
|
-
None,
|
|
330
|
-
project_name,
|
|
331
|
-
owner_id,
|
|
332
|
-
tags,
|
|
333
|
-
views,
|
|
334
|
-
data_acceleration_config,
|
|
335
|
-
)
|
|
300
|
+
workbook_item = cls.from_xml(workbook_xml, ns)
|
|
336
301
|
all_workbook_items.append(workbook_item)
|
|
337
302
|
return all_workbook_items
|
|
338
303
|
|
|
304
|
+
@classmethod
|
|
305
|
+
def from_xml(cls, workbook_xml, ns):
|
|
306
|
+
workbook_item = cls()
|
|
307
|
+
workbook_item._set_values(*cls._parse_element(workbook_xml, ns))
|
|
308
|
+
return workbook_item
|
|
309
|
+
|
|
339
310
|
@staticmethod
|
|
340
311
|
def _parse_element(workbook_xml, ns):
|
|
341
312
|
id = workbook_xml.get("id", None)
|
|
@@ -10,9 +10,7 @@ from .request_options import (
|
|
|
10
10
|
|
|
11
11
|
from .filter import Filter
|
|
12
12
|
from .sort import Sort
|
|
13
|
-
from ..models import *
|
|
14
13
|
from .endpoint import *
|
|
15
14
|
from .server import Server
|
|
16
15
|
from .pager import Pager
|
|
17
|
-
from .exceptions import NotSignedInError
|
|
18
|
-
from ..helpers import *
|
|
16
|
+
from .endpoint.exceptions import NotSignedInError
|
|
@@ -5,11 +5,7 @@ from .data_alert_endpoint import DataAlerts
|
|
|
5
5
|
from .databases_endpoint import Databases
|
|
6
6
|
from .datasources_endpoint import Datasources
|
|
7
7
|
from .endpoint import Endpoint, QuerysetEndpoint
|
|
8
|
-
from .exceptions import
|
|
9
|
-
ServerResponseError,
|
|
10
|
-
MissingRequiredFieldError,
|
|
11
|
-
ServerInfoEndpointNotFoundError,
|
|
12
|
-
)
|
|
8
|
+
from .exceptions import ServerResponseError, MissingRequiredFieldError
|
|
13
9
|
from .favorites_endpoint import Favorites
|
|
14
10
|
from .fileuploads_endpoint import Fileuploads
|
|
15
11
|
from .flow_runs_endpoint import FlowRuns
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
import warnings
|
|
2
4
|
|
|
3
5
|
from defusedxml.ElementTree import fromstring
|
|
4
6
|
|
|
@@ -6,7 +8,11 @@ from .endpoint import Endpoint, api
|
|
|
6
8
|
from .exceptions import ServerResponseError
|
|
7
9
|
from ..request_factory import RequestFactory
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
from tableauserverclient.helpers.logging import logger
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from tableauserverclient.models.site_item import SiteItem
|
|
15
|
+
from tableauserverclient.models.tableau_auth import Credentials
|
|
10
16
|
|
|
11
17
|
|
|
12
18
|
class Auth(Endpoint):
|
|
@@ -21,11 +27,21 @@ class Auth(Endpoint):
|
|
|
21
27
|
self._callback()
|
|
22
28
|
|
|
23
29
|
@property
|
|
24
|
-
def baseurl(self):
|
|
30
|
+
def baseurl(self) -> str:
|
|
25
31
|
return "{0}/auth".format(self.parent_srv.baseurl)
|
|
26
32
|
|
|
27
33
|
@api(version="2.0")
|
|
28
|
-
def sign_in(self, auth_req):
|
|
34
|
+
def sign_in(self, auth_req: "Credentials") -> contextmgr:
|
|
35
|
+
"""
|
|
36
|
+
Sign in to a Tableau Server or Tableau Online using a credentials object.
|
|
37
|
+
|
|
38
|
+
The credentials object can either be a TableauAuth object, a
|
|
39
|
+
PersonalAccessTokenAuth object, or a JWTAuth object. This method now
|
|
40
|
+
accepts them all. The object should be populated with the site_id and
|
|
41
|
+
optionally a user_id to impersonate.
|
|
42
|
+
|
|
43
|
+
Creates a context manager that will sign out of the server upon exit.
|
|
44
|
+
"""
|
|
29
45
|
url = "{0}/{1}".format(self.baseurl, "signin")
|
|
30
46
|
signin_req = RequestFactory.Auth.signin_req(auth_req)
|
|
31
47
|
server_response = self.parent_srv.session.post(
|
|
@@ -50,13 +66,18 @@ class Auth(Endpoint):
|
|
|
50
66
|
logger.info("Signed into {0} as user with id {1}".format(self.parent_srv.server_address, user_id))
|
|
51
67
|
return Auth.contextmgr(self.sign_out)
|
|
52
68
|
|
|
69
|
+
# We use the same request that username/password login uses for all auth types.
|
|
70
|
+
# The distinct methods are mostly useful for explicitly showing api version support for each auth type
|
|
53
71
|
@api(version="3.6")
|
|
54
|
-
def sign_in_with_personal_access_token(self, auth_req):
|
|
55
|
-
|
|
72
|
+
def sign_in_with_personal_access_token(self, auth_req: "Credentials") -> contextmgr:
|
|
73
|
+
return self.sign_in(auth_req)
|
|
74
|
+
|
|
75
|
+
@api(version="3.17")
|
|
76
|
+
def sign_in_with_json_web_token(self, auth_req: "Credentials") -> contextmgr:
|
|
56
77
|
return self.sign_in(auth_req)
|
|
57
78
|
|
|
58
79
|
@api(version="2.0")
|
|
59
|
-
def sign_out(self):
|
|
80
|
+
def sign_out(self) -> None:
|
|
60
81
|
url = "{0}/{1}".format(self.baseurl, "signout")
|
|
61
82
|
# If there are no auth tokens you're already signed out. No-op
|
|
62
83
|
if not self.parent_srv.is_signed_in():
|
|
@@ -66,7 +87,7 @@ class Auth(Endpoint):
|
|
|
66
87
|
logger.info("Signed out")
|
|
67
88
|
|
|
68
89
|
@api(version="2.6")
|
|
69
|
-
def switch_site(self, site_item):
|
|
90
|
+
def switch_site(self, site_item: "SiteItem") -> contextmgr:
|
|
70
91
|
url = "{0}/{1}".format(self.baseurl, "switchSite")
|
|
71
92
|
switch_req = RequestFactory.Auth.switch_req(site_item.content_url)
|
|
72
93
|
try:
|
|
@@ -87,7 +108,7 @@ class Auth(Endpoint):
|
|
|
87
108
|
return Auth.contextmgr(self.sign_out)
|
|
88
109
|
|
|
89
110
|
@api(version="3.10")
|
|
90
|
-
def revoke_all_server_admin_tokens(self):
|
|
111
|
+
def revoke_all_server_admin_tokens(self) -> None:
|
|
91
112
|
url = "{0}/{1}".format(self.baseurl, "revokeAllServerAdminTokens")
|
|
92
113
|
self.post_request(url, "")
|
|
93
114
|
logger.info("Revoked all tokens for all server admins")
|
|
@@ -6,7 +6,7 @@ from .exceptions import MissingRequiredFieldError
|
|
|
6
6
|
from tableauserverclient.models import CustomViewItem, PaginationItem
|
|
7
7
|
from tableauserverclient.server import RequestFactory, RequestOptions, ImageRequestOptions
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
from tableauserverclient.helpers.logging import logger
|
|
10
10
|
|
|
11
11
|
"""
|
|
12
12
|
Get a list of custom views on a site
|
|
@@ -5,7 +5,7 @@ from .endpoint import api, Endpoint
|
|
|
5
5
|
from .permissions_endpoint import _PermissionsEndpoint
|
|
6
6
|
from tableauserverclient.models import DataAccelerationReportItem
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
from tableauserverclient.helpers.logging import logger
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
class DataAccelerationReport(Endpoint):
|
|
@@ -5,7 +5,7 @@ from .exceptions import MissingRequiredFieldError
|
|
|
5
5
|
from tableauserverclient.server import RequestFactory
|
|
6
6
|
from tableauserverclient.models import DataAlertItem, PaginationItem, UserItem
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
from tableauserverclient.helpers.logging import logger
|
|
9
9
|
|
|
10
10
|
from typing import List, Optional, TYPE_CHECKING, Tuple, Union
|
|
11
11
|
|
|
@@ -8,7 +8,7 @@ from .permissions_endpoint import _PermissionsEndpoint
|
|
|
8
8
|
from tableauserverclient.server import RequestFactory
|
|
9
9
|
from tableauserverclient.models import DatabaseItem, TableItem, PaginationItem, Resource
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
from tableauserverclient.helpers.logging import logger
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
class Databases(Endpoint):
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import cgi
|
|
2
2
|
import copy
|
|
3
3
|
import json
|
|
4
|
-
import logging
|
|
5
4
|
import io
|
|
6
5
|
import os
|
|
7
6
|
|
|
@@ -20,13 +19,14 @@ from .exceptions import InternalServerError, MissingRequiredFieldError
|
|
|
20
19
|
from .permissions_endpoint import _PermissionsEndpoint
|
|
21
20
|
from .resource_tagger import _ResourceTagger
|
|
22
21
|
|
|
23
|
-
from tableauserverclient.
|
|
22
|
+
from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, FILESIZE_LIMIT_MB, BYTES_PER_MB, CHUNK_SIZE_MB
|
|
24
23
|
from tableauserverclient.filesys_helpers import (
|
|
25
|
-
to_filename,
|
|
26
24
|
make_download_path,
|
|
27
25
|
get_file_type,
|
|
28
26
|
get_file_object_size,
|
|
27
|
+
to_filename,
|
|
29
28
|
)
|
|
29
|
+
from tableauserverclient.helpers.logging import logger
|
|
30
30
|
from tableauserverclient.models import (
|
|
31
31
|
ConnectionCredentials,
|
|
32
32
|
ConnectionItem,
|
|
@@ -35,6 +35,7 @@ from tableauserverclient.models import (
|
|
|
35
35
|
RevisionItem,
|
|
36
36
|
PaginationItem,
|
|
37
37
|
)
|
|
38
|
+
from tableauserverclient.server import RequestFactory, RequestOptions
|
|
38
39
|
|
|
39
40
|
io_types = (io.BytesIO, io.BufferedReader)
|
|
40
41
|
io_types_r = (io.BytesIO, io.BufferedReader)
|
|
@@ -44,13 +45,6 @@ FilePath = Union[str, os.PathLike]
|
|
|
44
45
|
FileObject = Union[io.BufferedReader, io.BytesIO]
|
|
45
46
|
PathOrFile = Union[FilePath, FileObject]
|
|
46
47
|
|
|
47
|
-
# The maximum size of a file that can be published in a single request is 64MB
|
|
48
|
-
FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB
|
|
49
|
-
|
|
50
|
-
ALLOWED_FILE_EXTENSIONS = ["tds", "tdsx", "tde", "hyper", "parquet"]
|
|
51
|
-
|
|
52
|
-
logger = logging.getLogger("tableau.endpoint.datasources")
|
|
53
|
-
|
|
54
48
|
FilePath = Union[str, os.PathLike]
|
|
55
49
|
FileObjectR = Union[io.BufferedReader, io.BytesIO]
|
|
56
50
|
FileObjectW = Union[io.BufferedWriter, io.BytesIO]
|
|
@@ -162,12 +156,20 @@ class Datasources(QuerysetEndpoint):
|
|
|
162
156
|
|
|
163
157
|
# Update datasource connections
|
|
164
158
|
@api(version="2.3")
|
|
165
|
-
def update_connection(
|
|
159
|
+
def update_connection(
|
|
160
|
+
self, datasource_item: DatasourceItem, connection_item: ConnectionItem
|
|
161
|
+
) -> Optional[ConnectionItem]:
|
|
166
162
|
url = "{0}/{1}/connections/{2}".format(self.baseurl, datasource_item.id, connection_item.id)
|
|
167
163
|
|
|
168
164
|
update_req = RequestFactory.Connection.update_req(connection_item)
|
|
169
165
|
server_response = self.put_request(url, update_req)
|
|
170
|
-
|
|
166
|
+
connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)
|
|
167
|
+
if not connections:
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
if len(connections) > 1:
|
|
171
|
+
logger.debug("Multiple connections returned ({0})".format(len(connections)))
|
|
172
|
+
connection = list(filter(lambda x: x.id == connection_item.id, connections))[0]
|
|
171
173
|
|
|
172
174
|
logger.info(
|
|
173
175
|
"Updated datasource item (ID: {0} & connection item {1}".format(datasource_item.id, connection_item.id)
|
|
@@ -220,7 +222,7 @@ class Datasources(QuerysetEndpoint):
|
|
|
220
222
|
filename = os.path.basename(file)
|
|
221
223
|
file_extension = os.path.splitext(filename)[1][1:]
|
|
222
224
|
file_size = os.path.getsize(file)
|
|
223
|
-
|
|
225
|
+
logger.debug("Publishing file `{}`, size `{}`".format(filename, file_size))
|
|
224
226
|
# If name is not defined, grab the name from the file to publish
|
|
225
227
|
if not datasource_item.name:
|
|
226
228
|
datasource_item.name = os.path.splitext(filename)[0]
|
|
@@ -261,8 +263,12 @@ class Datasources(QuerysetEndpoint):
|
|
|
261
263
|
url += "&{0}=true".format("asJob")
|
|
262
264
|
|
|
263
265
|
# Determine if chunking is required (64MB is the limit for single upload method)
|
|
264
|
-
if file_size >=
|
|
265
|
-
logger.info(
|
|
266
|
+
if file_size >= FILESIZE_LIMIT_MB * BYTES_PER_MB:
|
|
267
|
+
logger.info(
|
|
268
|
+
"Publishing {} to server with chunking method (datasource over {}MB, chunk size {}MB)".format(
|
|
269
|
+
filename, FILESIZE_LIMIT_MB, CHUNK_SIZE_MB
|
|
270
|
+
)
|
|
271
|
+
)
|
|
266
272
|
upload_session_id = self.parent_srv.fileuploads.upload(file)
|
|
267
273
|
url = "{0}&uploadSessionId={1}".format(url, upload_session_id)
|
|
268
274
|
xml_request, content_type = RequestFactory.Datasource.publish_req_chunked(
|
|
@@ -10,7 +10,7 @@ if TYPE_CHECKING:
|
|
|
10
10
|
from ..server import Server
|
|
11
11
|
from ..request_options import RequestOptions
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
from tableauserverclient.helpers.logging import logger
|
|
14
14
|
|
|
15
15
|
# these are the only two items that can hold default permissions for another type
|
|
16
16
|
BaseItem = Union[DatabaseItem, ProjectItem]
|
|
@@ -5,7 +5,7 @@ from .exceptions import MissingRequiredFieldError
|
|
|
5
5
|
from tableauserverclient.server import RequestFactory
|
|
6
6
|
from tableauserverclient.models import DQWItem
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
from tableauserverclient.helpers.logging import logger
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
class _DataQualityWarningEndpoint(Endpoint):
|
|
@@ -1,24 +1,31 @@
|
|
|
1
|
+
from threading import Thread
|
|
2
|
+
from time import sleep
|
|
3
|
+
from tableauserverclient import datetime_helpers as datetime
|
|
4
|
+
|
|
1
5
|
import requests
|
|
2
|
-
import logging
|
|
3
6
|
from packaging.version import Version
|
|
4
7
|
from functools import wraps
|
|
5
8
|
from xml.etree.ElementTree import ParseError
|
|
6
|
-
from typing import Any, Callable, Dict, Optional, TYPE_CHECKING
|
|
9
|
+
from typing import Any, Callable, Dict, Optional, TYPE_CHECKING, Union
|
|
7
10
|
|
|
8
11
|
from .exceptions import (
|
|
9
12
|
ServerResponseError,
|
|
10
13
|
InternalServerError,
|
|
11
14
|
NonXMLResponseError,
|
|
12
|
-
|
|
15
|
+
NotSignedInError,
|
|
13
16
|
)
|
|
17
|
+
from ..exceptions import EndpointUnavailableError
|
|
18
|
+
|
|
14
19
|
from tableauserverclient.server.query import QuerySet
|
|
15
20
|
from tableauserverclient import helpers, get_versions
|
|
16
21
|
|
|
22
|
+
from tableauserverclient.helpers.logging import logger
|
|
23
|
+
from tableauserverclient.config import DELAY_SLEEP_SECONDS
|
|
24
|
+
|
|
17
25
|
if TYPE_CHECKING:
|
|
18
26
|
from ..server import Server
|
|
19
27
|
from requests import Response
|
|
20
28
|
|
|
21
|
-
logger = logging.getLogger("tableau.endpoint")
|
|
22
29
|
|
|
23
30
|
Success_codes = [200, 201, 202, 204]
|
|
24
31
|
|
|
@@ -34,6 +41,8 @@ class Endpoint(object):
|
|
|
34
41
|
def __init__(self, parent_srv: "Server"):
|
|
35
42
|
self.parent_srv = parent_srv
|
|
36
43
|
|
|
44
|
+
async_response = None
|
|
45
|
+
|
|
37
46
|
@staticmethod
|
|
38
47
|
def set_parameters(http_options, auth_token, content, content_type, parameters) -> Dict[str, Any]:
|
|
39
48
|
parameters = parameters or {}
|
|
@@ -53,6 +62,8 @@ class Endpoint(object):
|
|
|
53
62
|
|
|
54
63
|
@staticmethod
|
|
55
64
|
def set_user_agent(parameters):
|
|
65
|
+
if "headers" not in parameters:
|
|
66
|
+
parameters["headers"] = {}
|
|
56
67
|
if USER_AGENT_HEADER not in parameters["headers"]:
|
|
57
68
|
if USER_AGENT_HEADER in parameters:
|
|
58
69
|
parameters["headers"][USER_AGENT_HEADER] = parameters[USER_AGENT_HEADER]
|
|
@@ -65,6 +76,59 @@ class Endpoint(object):
|
|
|
65
76
|
# return explicitly for testing only
|
|
66
77
|
return parameters
|
|
67
78
|
|
|
79
|
+
def _blocking_request(self, method, url, parameters={}) -> Optional["Response"]:
|
|
80
|
+
self.async_response = None
|
|
81
|
+
response = None
|
|
82
|
+
logger.debug("[{}] Begin blocking request to {}".format(datetime.timestamp(), url))
|
|
83
|
+
try:
|
|
84
|
+
response = method(url, **parameters)
|
|
85
|
+
self.async_response = response
|
|
86
|
+
logger.debug("[{}] Call finished".format(datetime.timestamp()))
|
|
87
|
+
except Exception as e:
|
|
88
|
+
logger.debug("Error making request to server: {}".format(e))
|
|
89
|
+
self.async_response = e
|
|
90
|
+
finally:
|
|
91
|
+
if response and not self.async_response:
|
|
92
|
+
logger.debug("Request response not saved")
|
|
93
|
+
return None
|
|
94
|
+
logger.debug("[{}] Request complete".format(datetime.timestamp()))
|
|
95
|
+
return self.async_response
|
|
96
|
+
|
|
97
|
+
def send_request_while_show_progress_threaded(
|
|
98
|
+
self, method, url, parameters={}, request_timeout=0
|
|
99
|
+
) -> Optional["Response"]:
|
|
100
|
+
try:
|
|
101
|
+
request_thread = Thread(target=self._blocking_request, args=(method, url, parameters))
|
|
102
|
+
request_thread.async_response = -1 # type:ignore # this is an invented attribute for thread comms
|
|
103
|
+
request_thread.start()
|
|
104
|
+
except Exception as e:
|
|
105
|
+
logger.debug("Error starting server request on separate thread: {}".format(e))
|
|
106
|
+
return None
|
|
107
|
+
seconds = 0
|
|
108
|
+
minutes = 0
|
|
109
|
+
sleep(1)
|
|
110
|
+
if self.async_response != -1:
|
|
111
|
+
# a quick return for any immediate responses
|
|
112
|
+
return self.async_response
|
|
113
|
+
while self.async_response == -1 and (request_timeout == 0 or seconds < request_timeout):
|
|
114
|
+
self.log_wait_time_then_sleep(minutes, seconds, url)
|
|
115
|
+
seconds = seconds + DELAY_SLEEP_SECONDS
|
|
116
|
+
if seconds >= 60:
|
|
117
|
+
seconds = 0
|
|
118
|
+
minutes = minutes + 1
|
|
119
|
+
return self.async_response
|
|
120
|
+
|
|
121
|
+
def log_wait_time_then_sleep(self, minutes, seconds, url):
|
|
122
|
+
logger.debug("{} Waiting....".format(datetime.timestamp()))
|
|
123
|
+
if seconds >= 60: # detailed log message ~every minute
|
|
124
|
+
if minutes % 5 == 0:
|
|
125
|
+
logger.info(
|
|
126
|
+
"[{}] Waiting ({} minutes so far) for request to {}".format(datetime.timestamp(), minutes, url)
|
|
127
|
+
)
|
|
128
|
+
else:
|
|
129
|
+
logger.debug("[{}] Waiting for request to {}".format(datetime.timestamp(), url))
|
|
130
|
+
sleep(DELAY_SLEEP_SECONDS)
|
|
131
|
+
|
|
68
132
|
def _make_request(
|
|
69
133
|
self,
|
|
70
134
|
method: Callable[..., "Response"],
|
|
@@ -80,36 +144,59 @@ class Endpoint(object):
|
|
|
80
144
|
|
|
81
145
|
logger.debug("request method {}, url: {}".format(method.__name__, url))
|
|
82
146
|
if content:
|
|
83
|
-
redacted = helpers.strings.redact_xml(content[:
|
|
147
|
+
redacted = helpers.strings.redact_xml(content[:200])
|
|
148
|
+
# this needs to be under a trace or something, it's a LOT
|
|
84
149
|
# logger.debug("request content: {}".format(redacted))
|
|
85
150
|
|
|
86
|
-
|
|
151
|
+
# a request can, for stuff like publishing, spin for ages waiting for a response.
|
|
152
|
+
# we need some user-facing activity so they know it's not dead.
|
|
153
|
+
request_timeout = self.parent_srv.http_options.get("timeout") or 0
|
|
154
|
+
server_response: Optional["Response"] = self.send_request_while_show_progress_threaded(
|
|
155
|
+
method, url, parameters, request_timeout
|
|
156
|
+
)
|
|
157
|
+
logger.debug("[{}] Async request returned: received {}".format(datetime.timestamp(), server_response))
|
|
158
|
+
# is this blocking retry really necessary? I guess if it was just the threading messing it up?
|
|
159
|
+
if server_response is None:
|
|
160
|
+
logger.debug(server_response)
|
|
161
|
+
logger.debug("[{}] Async request failed: retrying".format(datetime.timestamp()))
|
|
162
|
+
server_response = self._blocking_request(method, url, parameters)
|
|
163
|
+
if server_response is None:
|
|
164
|
+
logger.debug("[{}] Request failed".format(datetime.timestamp()))
|
|
165
|
+
raise RuntimeError
|
|
87
166
|
self._check_status(server_response, url)
|
|
88
167
|
|
|
89
168
|
loggable_response = self.log_response_safely(server_response)
|
|
90
|
-
|
|
169
|
+
logger.debug("Server response from {0}".format(url))
|
|
170
|
+
# logger.debug("\n\t{1}".format(loggable_response))
|
|
91
171
|
|
|
92
172
|
if content_type == "application/xml":
|
|
93
173
|
self.parent_srv._namespace.detect(server_response.content)
|
|
94
174
|
|
|
95
175
|
return server_response
|
|
96
176
|
|
|
97
|
-
def _check_status(self, server_response, url: Optional[str] = None):
|
|
177
|
+
def _check_status(self, server_response: "Response", url: Optional[str] = None):
|
|
178
|
+
logger.debug("Response status: {}".format(server_response))
|
|
179
|
+
if not hasattr(server_response, "status_code"):
|
|
180
|
+
raise EnvironmentError("Response is not a http response?")
|
|
98
181
|
if server_response.status_code >= 500:
|
|
99
182
|
raise InternalServerError(server_response, url)
|
|
100
183
|
elif server_response.status_code not in Success_codes:
|
|
101
184
|
try:
|
|
185
|
+
if server_response.status_code == 401:
|
|
186
|
+
# TODO: catch this in server.py and attempt to sign in again, in case it's a session expiry
|
|
187
|
+
raise NotSignedInError(server_response.content, url)
|
|
188
|
+
|
|
102
189
|
raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace, url)
|
|
103
190
|
except ParseError:
|
|
104
191
|
# This will happen if we get a non-success HTTP code that doesn't return an xml error object
|
|
105
|
-
# e.g metadata endpoints, 503 pages, totally different servers
|
|
192
|
+
# e.g. metadata endpoints, 503 pages, totally different servers
|
|
106
193
|
# we convert this to a better exception and pass through the raw response body
|
|
107
194
|
raise NonXMLResponseError(server_response.content)
|
|
108
195
|
except Exception:
|
|
109
196
|
# anything else re-raise here
|
|
110
197
|
raise
|
|
111
198
|
|
|
112
|
-
def log_response_safely(self, server_response:
|
|
199
|
+
def log_response_safely(self, server_response: "Response") -> str:
|
|
113
200
|
# Checking the content type header prevents eager evaluation of streaming requests.
|
|
114
201
|
content_type = server_response.headers.get("Content-Type")
|
|
115
202
|
|
|
@@ -117,7 +204,7 @@ class Endpoint(object):
|
|
|
117
204
|
# content-type is an octet-stream accomplishes the same goal without eagerly loading content.
|
|
118
205
|
# This check is to determine if the response is a text response (xml or otherwise)
|
|
119
206
|
# so that we do not attempt to log bytes and other binary data.
|
|
120
|
-
loggable_response = "Content type {}".format(content_type)
|
|
207
|
+
loggable_response = "Content type `{}`".format(content_type)
|
|
121
208
|
if content_type == "application/octet-stream":
|
|
122
209
|
loggable_response = "A stream of type {} [Truncated File Contents]".format(content_type)
|
|
123
210
|
elif server_response.encoding and len(server_response.content) > 0:
|