8000 push code for 0.24 (#1168) · LehmD/server-client-python@514cc13 · GitHub
[go: up one dir, main page]

Skip to content

Commit 514cc13

Browse files
jacalataMrwanBaghdadjorwoodsbcantoniTrimPeachu
authored
push code for 0.24 (tableau#1168)
* Allow injection of sessions (tableau#1111) * show server info (tableau#1118) * Fix bug in exposing ExcelRequestOptions and test (tableau#1123) * Fix a few pylint errors (tableau#1124) * fix behavior when url has no protocol (tableau#1125) * Add permission control for Data Roles and Metrics (Issue tableau#1063) (tableau#1120) * add option to pass specific datasources (tableau#1150) * allow user agent to be set by caller (tableau#1166) * Fix issues with connections publishing workbooks (tableau#1171) * Allow download to file-like objects (tableau#1172) * Add updated_at to JobItem class (tableau#1182) * fix revision references where xml returned does not match docs (tableau#1176) * Do not create empty connections list (tableau#1178) --------- Co-authored-by: Marwan Baghdad <mrwanbaghdad76@gmail.com> Co-authored-by: jorwoods <jorwoods@users.noreply.github.com> Co-authored-by: Brian Cantoni <bcantoni@salesforce.com> Co-authored-by: TrimPeachu <77048868+TrimPeachu@users.noreply.github.com> Co-authored-by: Stu Tomlinson <stu@nosnilmot.com> Co-authored-by: Jeremy Harris <jercharris89@gmail.com>
1 parent a29ba6c commit 514cc13

25 files changed

+439
-226
lines changed

tableauserverclient/models/datasource_item.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class AskDataEnablement:
3434
Disabled = "Disabled"
3535
SiteDefault = "SiteDefault"
3636

37-
def __init__(self, project_id: str, name: str = None) -> None:
37+
def __init__(self, project_id: str, name: Optional[str] = None) -> None:
3838
self._ask_data_enablement = None
3939
self._certified = None
4040
self._certification_note = None

tableauserverclient/models/job_item.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ def __init__(
3434
workbook_id: Optional[str] = None,
3535
datasource_id: Optional[str] = None,
3636
flow_run: Optional[FlowRunItem] = None,
37+
updated_at: Optional["datetime.datetime"] = None,
3738
):
3839
self._id = id_
3940
self._type = job_type
@@ -47,6 +48,7 @@ def __init__(
4748
self._workbook_id = workbook_id
4849
self._datasource_id = datasource_id
4950
self._flow_run = flow_run
51+
self._updated_at = updated_at
5052

5153
@property
5254
def id(self) -> str:
@@ -113,9 +115,13 @@ def flow_run(self):
113115
def flow_run(self, value):
114116
self._flow_run = value
115117

118+
@property
119+
def updated_at(self) -> Optional["datetime.datetime"]:
120+
return self._updated_at
121+
116122
def __repr__(self):
117123
return (
118-
"<Job#{_id} {_type} created_at({_created_at}) started_at({_started_at}) completed_at({_completed_at})"
124+
"<Job#{_id} {_type} created_at({_created_at}) started_at({_started_at}) updated_at({_updated_at}) completed_at({_completed_at})"
119125
" progress ({_progress}) finish_code({_finish_code})>".format(**self.__dict__)
120126
)
121127

@@ -144,6 +150,7 @@ def _parse_element(cls, element, ns):
144150
datasource = element.find(".//t:datasource[@id]", namespaces=ns)
145151
datasource_id = datasource.get("id") if datasource is not None else None
146152
flow_run = None
153+
updated_at = parse_datetime(element.get("updatedAt", None))
147154
for flow_job in element.findall(".//t:runFlowJobType", namespaces=ns):
148155
flow_run = FlowRunItem()
149156
flow_run._id = flow_job.get("flowRunId", None)
@@ -163,6 +170,7 @@ def _parse_element(cls, element, ns):
163170
workbook_id,
164171
datasource_id,
165172
flow_run,
173+
updated_at,
166174
)
167175

168176

tableauserverclient/models/revision_item.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,10 @@ def from_response(cls, resp: bytes, ns, resource_item) -> List["RevisionItem"]:
6767
revision_item._resource_id = resource_item.id
6868
revision_item._resource_name = resource_item.name
6969
revision_item._revision_number = revision_xml.get("revisionNumber", None)
70-
revision_item._current = string_to_bool(revision_xml.get("isCurrent", ""))
71-
revision_item._deleted = string_to_bool(revision_xml.get("isDeleted", ""))
72-
revision_item._created_at = parse_datetime(revision_xml.get("createdAt", None))
73-
for user in revision_xml.findall(".//t:user", namespaces=ns):
70+
revision_item._current = string_to_bool(revision_xml.get("current", ""))
71+
revision_item._deleted = string_to_bool(revision_xml.get("deleted", ""))
72+
revision_item._created_at = parse_datetime(revision_xml.get("publishedAt", None))
73+
for user in revision_xml.findall(".//t:publisher", namespaces=ns):
7474
revision_item._user_id = user.get("id", None)
7575
revision_item._user_name = user.get("name", None)
7676

tableauserverclient/models/site_item.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,9 @@ def __init__(
5050
self,
5151
name: str,
5252
content_url: str,
53-
admin_mode: str = None,
54-
user_quota: int = None,
55-
storage_quota: int = None,
53+
admin_mode: Optional[str] = None,
54+
user_quota: Optional[int] = None,
55+
storage_quota: Optional[int] = None,
5656
disable_subscriptions: bool = False,
5757
subscribe_others_enabled: bool = True,
5858
revision_history_enabled: bool = False,

tableauserverclient/models/workbook_item.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333

3434

3535
class WorkbookItem(object):
36-
def __init__(self, project_id: str, name: str = None, show_tabs: bool = False) -> None:
36+
def __init__(self, project_id: str, name: Optional[str] = None, show_tabs: bool = False) -> None:
3737
self._connections = None
3838
self._content_url = None
3939
self._webpage_url = None

tableauserverclient/server/endpoint/datasources_endpoint.py

Lines changed: 32 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -31,22 +31,9 @@
3131
)
3232
from ...models import ConnectionCredentials, RevisionItem
3333
from ...models.job_item import JobItem
34-
from ...models import ConnectionCredentials
3534

36-
io_types = (io.BytesIO, io.BufferedReader)
37-
38-
from pathlib import Path
39-
from typing import (
40-
List,
41-
Mapping,
42-
Optional,
43-
Sequence,
44-
Tuple,
45-
TYPE_CHECKING,
46-
Union,
47-
)
48-
49-
io_types = (io.BytesIO, io.BufferedReader)
35+
io_types_r = (io.BytesIO, io.BufferedReader)
36+
io_types_w = (io.BytesIO, io.BufferedWriter)
5037

5138
# The maximum size of a file that can be published in a single request is 64MB
5239
FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB
@@ -61,8 +48,10 @@
6148
from .schedules_endpoint import AddResponse
6249

6350
FilePath = Union[str, os.PathLike]
64-
FileObject = Union[io.BufferedReader, io.BytesIO]
65-
PathOrFile = Union[FilePath, FileObject]
51+
FileObjectR = Union[io.BufferedReader, io.BytesIO]
52+
FileObjectW = Union[io.BufferedWriter, io.BytesIO]
53+
PathOrFileR = Union[FilePath, FileObjectR]
54+
PathOrFileW = Union[FilePath, FileObjectW]
6655

6756

6857
class Datasources(QuerysetEndpoint):
@@ -80,7 +69,7 @@ def baseurl(self) -> str:
8069

8170
# Get all datasources
8271
@api(version="2.0")
83-
def get(self, req_options: RequestOptions = None) -> Tuple[List[DatasourceItem], PaginationItem]:
72+
def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[DatasourceItem], PaginationItem]:
8473
logger.info("Querying all datasources on site")
8574
url = self.baseurl
8675
server_response = self.get_request(url, req_options)
@@ -135,39 +124,11 @@ def delete(self, datasource_id: str) -> None:
135124
def download(
136125
self,
137126
datasource_id: str,
138-
filepath: FilePath = None,
127+
filepath: Optional[PathOrFileW] = None,
139128
include_extract: bool = True,
140129
no_extract: Optional[bool] = None,
141130
) -> str:
142-
if not datasource_id:
143-
error = "Datasource ID undefined."
144-
raise ValueError(error)
145-
url = "{0}/{1}/content".format(self.baseurl, datasource_id)
146-
147-
if no_extract is False or no_extract is True:
148-
import warnings
149-
150-
warnings.warn(
151-
"no_extract is deprecated, use include_extract instead.",
152-
DeprecationWarning,
153-
)
154-
include_extract = not no_extract
155-
156-
if not include_extract:
157-
url += "?includeExtract=False"
158-
159-
with closing(self.get_request(url, parameters={"stream": True})) as server_response:
160-
_, params = cgi.parse_header(server_response.headers["Content-Disposition"])
161-
filename = to_filename(os.path.basename(params["filename"]))
162-
163-
download_path = make_download_path(filepath, filename)
164-
165-
with open(download_path, "wb") as f:
166-
for chunk in server_response.iter_content(1024): # 1KB
167-
f.write(chunk)
168-
169-
logger.info("Downloaded datasource to {0} (ID: {1})".format(download_path, datasource_id))
170-
return os.path.abspath(download_path)
131+
return self.download_revision(datasource_id, None, filepath, include_extract, no_extract)
171132

172133
# Update datasource
173134
@api(version="2.0")
@@ -232,10 +193,10 @@ def delete_extract(self, datasource_item: DatasourceItem) -> None:
232193
def publish(
233194
self,
234195
datasource_item: DatasourceItem,
235-
file: PathOrFile,
196+
file: PathOrFileR,
236197
mode: str,
237-
connection_credentials: ConnectionCredentials = None,
238-
connections: Sequence[ConnectionItem] = None,
198+
connection_credentials: Optional[ConnectionCredentials] = None,
199+
connections: Optional[Sequence[ConnectionItem]] = None,
239200
as_job: bool = False,
240201
) -> Union[DatasourceItem, JobItem]:
241202

@@ -255,8 +216,7 @@ def publish(
255216
error = "Only {} files can be published as datasources.".format(", ".join(ALLOWED_FILE_EXTENSIONS))
256217
raise ValueError(error)
257218

258-
elif isinstance(file, io_types):
259-
219+
elif isinstance(file, io_types_r):
260220
if not datasource_item.name:
261221
error = "Datasource item must have a name when passing a file object"
262222
raise ValueError(error)
@@ -302,7 +262,7 @@ def publish(
302262
if isinstance(file, (Path, str)):
303263
with open(file, "rb") as f:
304264
file_contents = f.read()
305-
elif isinstance(file, io_types):
265+
elif isinstance(file, io_types_r):
306266
file_contents = file.read()
307267
else:
308268
raise TypeError("file should be a filepath or file object.")
@@ -433,14 +393,17 @@ def download_revision(
433393
self,
434394
datasource_id: str,
435395
revision_number: str,
436-
filepath: Optional[PathOrFile] = None,
396+
filepath: Optional[PathOrFileW] = None,
437397
include_extract: bool = True,
438398
no_extract: Optional[bool] = None,
439-
) -> str:
399+
) -> PathOrFileW:
440400
if not datasource_id:
441401
error = "Datasource ID undefined."
442402
raise ValueError(error)
443-
url = "{0}/{1}/revisions/{2}/content".format(self.baseurl, datasource_id, revision_number)
403+
if revision_number is None:
404+
url = "{0}/{1}/content".format(self.baseurl, datasource_id)
405+
else:
406+
url = "{0}/{1}/revisions/{2}/content".format(self.baseurl, datasource_id, revision_number)
444407
if no_extract is False or no_extract is True:
445408
import warnings
446409

@@ -455,18 +418,22 @@ def download_revision(
455418

456419
with closing(self.get_request(url, parameters={"stream": True})) as server_response:
457420
_, params = cgi.parse_header(server_response.headers["Content-Disposition"])
458-
filename = to_filename(os.path.basename(params["filename"]))
459-
460-
download_path = make_download_path(filepath, filename)
461-
462-
with open(download_path, "wb") as f:
421+
if isinstance(filepath, io_types_w):
463422
for chunk in server_response.iter_content(1024): # 1KB
464-
f.write(chunk)
423+
filepath.write(chunk)
424+
return_path = filepath
425+
else:
426+
filename = to_filename(os.path.basename(params["filename"]))
427+
download_path = make_download_path(filepath, filename)
428+
with open(download_path, "wb") as f:
429+
for chunk in server_response.iter_content(1024): # 1KB
430+
f.write(chunk)
431+
return_path = os.path.abspath(download_path)
465432

466433
logger.info(
467-
"Downloaded datasource revision {0} to {1} (ID: {2})".format(revision_number, download_path, datasource_id)
434+
"Downloaded datasource revision {0} to {1} (ID: {2})".format(revision_number, return_path, datasource_id)
468435
)
469-
return os.path.abspath(download_path)
436+
return return_path
470437

471438
@api(version="2.3")
472439
def delete_revision(self, datasource_id: str, revision_number: str) -> None:

tableauserverclient/server/endpoint/endpoint.py

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from packaging.version import Version
44
from functools import wraps
55
from xml.etree.ElementTree import ParseError
6-
from typing import Any, Callable, Dict, Optional, TYPE_CHECKING
6+
from typing import Any, Callable, Dict, Optional, TYPE_CHECKING, Mapping
77

88
from .exceptions import (
99
ServerResponseError,
@@ -35,15 +35,35 @@ def __init__(self, parent_srv: "Server"):
3535
self.parent_srv = parent_srv
3636

3737
@staticmethod
38-
def _make_common_headers(auth_token, content_type):
39-
_client_version: Optional[str] = get_versions()["version"]
40-
headers = {}
38+
def set_parameters(http_options, auth_token, content, content_type, parameters) -> Dict[str, Any]:
39+
parameters = parameters or {}
40+
parameters.update(http_options)
41+
if "headers" not in parameters:
42+
parameters["headers"] = {}
43+
4144
if auth_token is not None:
42-
headers[TABLEAU_AUTH_HEADER] = auth_token
45+
parameters["headers"][TABLEAU_AUTH_HEADER] = auth_token
4346
if content_type is not None:
44-
headers[CONTENT_TYPE_HEADER] = content_type
45-
headers[USER_AGENT_HEADER] = "Tableau Server Client/{}".format(_client_version)
46-
return headers
47+
parameters["headers"][CONTENT_TYPE_HEADER] = content_type
48+
49+
Endpoint.set_user_agent(parameters)
50+
if content is not None:
51+
parameters["data"] = content
52+
return parameters or {}
53+
54+
@staticmethod
55+
def set_user_agent(parameters):
56+
if USER_AGENT_HEADER not in parameters["headers"]:
57+
if USER_AGENT_HEADER in parameters:
58+
parameters["headers"][USER_AGENT_HEADER] = parameters[USER_AGENT_HEADER]
59+
else:
60+
# only set the TSC user agent if not already populated
61+
_client_version: Optional[str] = get_versions()["version"]
62+
parameters["headers"][USER_AGENT_HEADER] = "Tableau Server Client/{}".format(_client_version)
63+
64+
# result: parameters["headers"]["User-Agent"] is set
65+
# return explicitly for testing only
66+
return parameters
4767

4868
def _make_request(
4969
self,
@@ -54,18 +74,14 @@ def _make_request(
5474
content_type: Optional[str] = None,
5575
parameters: Optional[Dict[str, Any]] = None,
5676
) -> "Response":
57-
parameters = parameters or {}
58-
if "headers" not in parameters:
59-
parameters["headers"] = {}
60-
parameters.update(self.parent_srv.http_options)
61-
parameters["headers"].update(Endpoint._make_common_headers(auth_token, content_type))
62-
63-
if content is not None:
64-
parameters["data"] = content
77+
parameters = Endpoint.set_parameters(
78+
self.parent_srv.http_options, auth_token, content, content_type, parameters
79+
)
6580

66-
logger.debug("request {}, url: {}".format(method.__name__, url))
81+
logger.debug("request {}, url: {}".format(method, url))
6782
if content:
68-
logger.debug("request content: {}".format(helpers.strings.redact_xml(content[:1000])))
83+
redacted = helpers.strings.redact_xml(content[:1000])
84+
logger.debug("request content: {}".format(redacted))
6985

7086
server_response = method(url, **parameters)
7187
self._check_status(server_response, url)
@@ -78,7 +94,7 @@ def _make_request(
7894

7995
return server_response
8096

81-
def _check_status(self, server_response, url: str = None):
97+
def _check_status(self, server_response, url: Optional[str] = None):
8298
if server_response.status_code >= 500:
8399
raise InternalServerError(server_response, url)
84100
elif server_response.status_code not in Success_codes:

tableauserverclient/server/endpoint/exceptions.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from defusedxml.ElementTree import fromstring
2+
from typing import Optional
23

34

45
class TableauError(Exception):
@@ -33,7 +34,7 @@ def from_response(cls, resp, ns, url=None):
3334

3435

3536
class InternalServerError(TableauError):
36-
def __init__(self, server_response, request_url: str = None):
37+
def __init__(self, server_response, request_url: Optional[str] = None):
3738
self.code = server_response.status_code
3839
self.content = server_response.content
3940
self.url = request_url or "server"

0 commit comments

Comments
 (0)
0