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.
Files changed (77) hide show
  1. tableauserverclient/__init__.py +42 -1
  2. tableauserverclient/_version.py +4 -4
  3. tableauserverclient/config.py +13 -0
  4. tableauserverclient/datetime_helpers.py +4 -0
  5. tableauserverclient/helpers/logging.py +4 -0
  6. tableauserverclient/models/__init__.py +1 -1
  7. tableauserverclient/models/column_item.py +3 -0
  8. tableauserverclient/models/connection_credentials.py +7 -0
  9. tableauserverclient/models/connection_item.py +1 -1
  10. tableauserverclient/models/custom_view_item.py +5 -0
  11. tableauserverclient/models/data_acceleration_report_item.py +3 -0
  12. tableauserverclient/models/datasource_item.py +10 -54
  13. tableauserverclient/models/favorites_item.py +56 -40
  14. tableauserverclient/models/fileupload_item.py +2 -2
  15. tableauserverclient/models/flow_item.py +30 -25
  16. tableauserverclient/models/group_item.py +1 -4
  17. tableauserverclient/models/interval_item.py +12 -0
  18. tableauserverclient/models/job_item.py +10 -1
  19. tableauserverclient/models/metric_item.py +36 -29
  20. tableauserverclient/models/pagination_item.py +3 -0
  21. tableauserverclient/models/permissions_item.py +8 -5
  22. tableauserverclient/models/project_item.py +11 -13
  23. tableauserverclient/models/schedule_item.py +6 -7
  24. tableauserverclient/models/server_info_item.py +2 -2
  25. tableauserverclient/models/site_item.py +3 -0
  26. tableauserverclient/models/subscription_item.py +8 -0
  27. tableauserverclient/models/table_item.py +6 -0
  28. tableauserverclient/models/tableau_auth.py +41 -6
  29. tableauserverclient/models/tableau_types.py +4 -2
  30. tableauserverclient/models/user_item.py +5 -1
  31. tableauserverclient/models/view_item.py +39 -36
  32. tableauserverclient/models/workbook_item.py +14 -43
  33. tableauserverclient/server/__init__.py +1 -3
  34. tableauserverclient/server/endpoint/__init__.py +1 -5
  35. tableauserverclient/server/endpoint/auth_endpoint.py +29 -8
  36. tableauserverclient/server/endpoint/custom_views_endpoint.py +1 -1
  37. tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py +1 -1
  38. tableauserverclient/server/endpoint/data_alert_endpoint.py +1 -1
  39. tableauserverclient/server/endpoint/databases_endpoint.py +1 -1
  40. tableauserverclient/server/endpoint/datasources_endpoint.py +21 -15
  41. tableauserverclient/server/endpoint/default_permissions_endpoint.py +1 -1
  42. tableauserverclient/server/endpoint/dqw_endpoint.py +1 -1
  43. tableauserverclient/server/endpoint/endpoint.py +98 -11
  44. tableauserverclient/server/endpoint/exceptions.py +1 -5
  45. tableauserverclient/server/endpoint/favorites_endpoint.py +71 -29
  46. tableauserverclient/server/endpoint/fileuploads_endpoint.py +11 -10
  47. tableauserverclient/server/endpoint/flow_runs_endpoint.py +1 -1
  48. tableauserverclient/server/endpoint/flows_endpoint.py +5 -5
  49. tableauserverclient/server/endpoint/groups_endpoint.py +5 -2
  50. tableauserverclient/server/endpoint/jobs_endpoint.py +1 -1
  51. tableauserverclient/server/endpoint/metadata_endpoint.py +1 -1
  52. tableauserverclient/server/endpoint/metrics_endpoint.py +1 -1
  53. tableauserverclient/server/endpoint/permissions_endpoint.py +1 -1
  54. tableauserverclient/server/endpoint/projects_endpoint.py +3 -1
  55. tableauserverclient/server/endpoint/resource_tagger.py +3 -3
  56. tableauserverclient/server/endpoint/schedules_endpoint.py +2 -1
  57. tableauserverclient/server/endpoint/server_info_endpoint.py +2 -4
  58. tableauserverclient/server/endpoint/sites_endpoint.py +1 -1
  59. tableauserverclient/server/endpoint/subscriptions_endpoint.py +1 -1
  60. tableauserverclient/server/endpoint/tables_endpoint.py +1 -1
  61. tableauserverclient/server/endpoint/tasks_endpoint.py +12 -1
  62. tableauserverclient/server/endpoint/users_endpoint.py +1 -1
  63. tableauserverclient/server/endpoint/views_endpoint.py +1 -1
  64. tableauserverclient/server/endpoint/webhooks_endpoint.py +1 -1
  65. tableauserverclient/server/endpoint/workbooks_endpoint.py +4 -2
  66. tableauserverclient/server/exceptions.py +8 -1
  67. tableauserverclient/server/filter.py +5 -1
  68. tableauserverclient/server/request_factory.py +56 -12
  69. tableauserverclient/server/request_options.py +4 -2
  70. tableauserverclient/server/server.py +12 -13
  71. {tableauserverclient-0.25.dist-info → tableauserverclient-0.27.post0.dev1.dist-info}/METADATA +12 -10
  72. tableauserverclient-0.27.post0.dev1.dist-info/RECORD +97 -0
  73. {tableauserverclient-0.25.dist-info → tableauserverclient-0.27.post0.dev1.dist-info}/WHEEL +1 -1
  74. tableauserverclient-0.25.dist-info/RECORD +0 -95
  75. {tableauserverclient-0.25.dist-info → tableauserverclient-0.27.post0.dev1.dist-info}/LICENSE +0 -0
  76. {tableauserverclient-0.25.dist-info → tableauserverclient-0.27.post0.dev1.dist-info}/LICENSE.versioneer +0 -0
  77. {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
- self.project_id = project_id
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 __repr__(self):
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
- logger = logging.getLogger("tableau.endpoint.auth")
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
- # We use the same request that username/password login uses.
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
- logger = logging.getLogger("tableau.endpoint.custom_views")
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
- logger = logging.getLogger("tableau.endpoint.data_acceleration_report")
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
- logger = logging.getLogger("tableau.endpoint.dataAlerts")
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
- logger = logging.getLogger("tableau.endpoint.databases")
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.server import RequestFactory, RequestOptions
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(self, datasource_item: DatasourceItem, connection_item: ConnectionItem) -> ConnectionItem:
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
- connection = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0]
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 >= FILESIZE_LIMIT:
265
- logger.info("Publishing {0} to server with chunking method (datasource over 64MB)".format(filename))
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
- logger = logging.getLogger(__name__)
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
- logger = logging.getLogger(__name__)
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
- EndpointUnavailableError,
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[:1000])
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
- server_response = method(url, **parameters)
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
- # logger.debug("Server response from {0}:\n\t{1}".format(url, loggable_response))
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: requests.Response) -> str:
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:
@@ -47,11 +47,7 @@ class MissingRequiredFieldError(TableauError):
47
47
  pass
48
48
 
49
49
 
50
- class ServerInfoEndpointNotFoundError(TableauError):
51
- pass
52
-
53
-
54
- class EndpointUnavailableError(TableauError):
50
+ class NotSignedInError(TableauError):
55
51
  pass
56
52
 
57
53