Skip to content

Commit 759ed50

Browse files
authored
Merge branch 'development' into bcantoni/pytest-parallel
2 parents 308d1a1 + 1e116f3 commit 759ed50

31 files changed

+3769
-3445
lines changed

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ dependencies = [
1515
'defusedxml>=0.7.1', # latest as at 7/31/23
1616
'packaging>=23.1', # latest as at 7/31/23
1717
'requests>=2.32', # latest as at 7/31/23
18-
'urllib3>=2.2.2,<3',
18+
'urllib3>=2.6.0,<3',
1919
'typing_extensions>=4.0',
2020
]
2121
requires-python = ">=3.9"
@@ -32,7 +32,7 @@ classifiers = [
3232
repository = "https://github.com/tableau/server-client-python"
3333

3434
[project.optional-dependencies]
35-
test = ["black==24.8", "build", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests",
35+
test = ["black==24.10", "build", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests",
3636
"pytest-xdist", "requests-mock>=1.0,<2.0", "types-requests>=2.32.4.20250913"]
3737
[tool.black]
3838
line-length = 120

tableauserverclient/models/schedule_item.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import xml.etree.ElementTree as ET
22
from datetime import datetime
3-
from typing import Optional, Union
3+
from typing import Optional, Union, TYPE_CHECKING
44

55
from defusedxml.ElementTree import fromstring
66

@@ -16,6 +16,10 @@
1616
property_is_enum,
1717
)
1818

19+
if TYPE_CHECKING:
20+
from requests import Response
21+
22+
1923
Interval = Union[HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval]
2024

2125

@@ -407,3 +411,8 @@ def _read_warnings(parsed_response, ns):
407411
for warning_xml in all_warning_xml:
408412
warnings.append(warning_xml.get("message", None))
409413
return warnings
414+
415+
416+
def parse_batch_schedule_state(response: "Response", ns) -> list[str]:
417+
xml = fromstring(response.content)
418+
return [text for tag in xml.findall(".//t:scheduleLuid", namespaces=ns) if (text := tag.text)]

tableauserverclient/server/endpoint/schedules_endpoint.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
1+
from collections.abc import Iterable
12
import copy
23
import logging
34
import warnings
45
from collections import namedtuple
5-
from typing import TYPE_CHECKING, Callable, Optional, Union
6+
from typing import TYPE_CHECKING, Any, Callable, Literal, Optional, Union, overload
67

78
from .endpoint import Endpoint, api, parameter_added_in
89
from .exceptions import MissingRequiredFieldError
910
from tableauserverclient.server import RequestFactory
1011
from tableauserverclient.models import PaginationItem, ScheduleItem, TaskItem, ExtractItem
12+
from tableauserverclient.models.schedule_item import parse_batch_schedule_state
1113

1214
from tableauserverclient.helpers.logging import logger
1315

@@ -279,3 +281,48 @@ def get_extract_refresh_tasks(
279281
extract_items = ExtractItem.from_response(server_response.content, self.parent_srv.namespace)
280282

281283
return extract_items, pagination_item
284+
285+
@overload
286+
def batch_update_state(
287+
self,
288+
schedules: Iterable[ScheduleItem | str],
289+
state: Literal["active", "suspended"],
290+
update_all: Literal[False] = False,
291+
) -> list[str]: ...
292+
293+
@overload
294+
def batch_update_state(
295+
self, schedules: Any, state: Literal["active", "suspended"], update_all: Literal[True]
296+
) -> list[str]: ...
297+
298+
@api(version="3.27")
299+
def batch_update_state(self, schedules, state, update_all=False) -> list[str]:
300+
"""
301+
Batch update the status of one or more scheudles. If update_all is set,
302+
all schedules on the Tableau Server are affected.
303+
304+
Parameters
305+
----------
306+
schedules: Iterable[ScheudleItem | str] | Any
307+
The schedules to be updated. If update_all=True, this is ignored.
308+
309+
state: Literal["active", "suspended"]
310+
The state of the schedules, whether active or suspended.
311+
312+
update_all: bool
313+
Whether or not to apply the status to all schedules.
314+
315+
Returns
316+
-------
317+
List[str]
318+
The IDs of the affected schedules.
319+
"""
320+
params = {"state": state}
321+
if update_all:
322+
params["updateAll"] = "true"
323+
payload = RequestFactory.Empty.empty_req()
324+
else:
325+
payload = RequestFactory.Schedule.batch_update_state(schedules)
326+
327+
response = self.put_request(self.baseurl, payload, parameters={"params": params})
328+
return parse_batch_schedule_state(response, self.parent_srv.namespace)

tableauserverclient/server/endpoint/views_endpoint.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,29 @@ def update(self, view_item: ViewItem) -> ViewItem:
371371
# Returning view item to stay consistent with datasource/view update functions
372372
return view_item
373373

374+
@api(version="3.27")
375+
def delete(self, view: ViewItem | str) -> None:
376+
"""
377+
Deletes a view in a workbook. If you delete the only view in a workbook,
378+
the workbook is deleted. Can be used to remove hidden views when
379+
republishing or migrating to a different environment.
380+
381+
REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#delete_view
382+
383+
Parameters
384+
----------
385+
view: ViewItem | str
386+
The ViewItem or the luid for the view to be deleted.
387+
388+
Returns
389+
-------
390+
None
391+
"""
392+
id_ = getattr(view, "id", view)
393+
self.delete_request(f"{self.baseurl}/{id_}")
394+
logger.info(f"View({id_}) deleted.")
395+
return None
396+
374397
@api(version="1.0")
375398
def add_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) -> set[str]:
376399
"""

tableauserverclient/server/request_factory.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,16 @@ def add_datasource_req(self, id_: Optional[str], task_type: str = TaskItem.Type.
643643
def add_flow_req(self, id_: Optional[str], task_type: str = TaskItem.Type.RunFlow) -> bytes:
644644
return self._add_to_req(id_, "flow", task_type)
645645

646+
@_tsrequest_wrapped
647+
def batch_update_state(self, xml: ET.Element, schedules: Iterable[ScheduleItem | str]) -> None:
648+
luids = ET.SubElement(xml, "scheduleLuids")
649+
for schedule in schedules:
650+
luid = getattr(schedule, "id", schedule)
651+
if not isinstance(luid, str):
652+
continue
653+
luid_tag = ET.SubElement(luids, "scheduleLuid")
654+
luid_tag.text = luid
655+
646656

647657
class SiteRequest:
648658
def update_req(self, site_item: "SiteItem", parent_srv: Optional["Server"] = None):
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<tsResponse xmlns="http://tableau.com/api" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://tableau.com/api http://tableau.com/api/ts-api-2.3.xsd">
2+
<scheduleLuids>
3+
<scheduleLuid>593d2ebf-0d18-4deb-9d21-b113a4902583</scheduleLuid>
4+
<scheduleLuid>cecbb71e-def0-4030-8068-5ae50f51db1c</scheduleLuid>
5+
<scheduleLuid>f39a6e7d-405e-4c07-8c18-95845f9da80e</scheduleLuid>
6+
</scheduleLuids>
7+
</tsResponse>

test/http/test_http_requests.py

Lines changed: 101 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import pytest
12
import tableauserverclient as TSC
23
import unittest
34
import requests
@@ -27,91 +28,103 @@ def __init__(self, status_code):
2728
return MockResponse(200)
2829

2930

30-
class ServerTests(unittest.TestCase):
31-
def test_init_server_model_empty_throws(self):
32-
with self.assertRaises(TypeError):
33-
server = TSC.Server()
34-
35-
def test_init_server_model_no_protocol_defaults_htt(self):
36-
server = TSC.Server("fake-url")
37-
38-
def test_init_server_model_valid_server_name_works(self):
39-
server = TSC.Server("http://fake-url")
40-
41-
def test_init_server_model_valid_https_server_name_works(self):
42-
# by default, it will just set the version to 2.3
43-
server = TSC.Server("https://fake-url")
44-
45-
def test_init_server_model_bad_server_name_not_version_check(self):
46-
server = TSC.Server("fake-url", use_server_version=False)
47-
48-
@mock.patch("requests.sessions.Session.get", side_effect=mocked_requests_get)
49-
def test_init_server_model_bad_server_name_do_version_check(self, mock_get):
50-
server = TSC.Server("fake-url", use_server_version=True)
51-
52-
def test_init_server_model_bad_server_name_not_version_check_random_options(self):
53-
# with self.assertRaises(MissingSchema):
54-
server = TSC.Server("fake-url", use_server_version=False, http_options={"foo": 1})
55-
56-
def test_init_server_model_bad_server_name_not_version_check_real_options(self):
57-
# with self.assertRaises(ValueError):
58-
server = TSC.Server("fake-url", use_server_version=False, http_options={"verify": False})
59-
60-
def test_http_options_skip_ssl_works(self):
61-
http_options = {"verify": False}
62-
server = TSC.Server("http://fake-url")
63-
server.add_http_options(http_options)
64-
65-
def test_http_options_multiple_options_works(self):
66-
http_options = {"verify": False, "birdname": "Parrot"}
67-
server = TSC.Server("http://fake-url")
68-
server.add_http_options(http_options)
69-
70-
# ValueError: dictionary update sequence element #0 has length 1; 2 is required
71-
def test_http_options_multiple_dicts_fails(self):
72-
http_options_1 = {"verify": False}
73-
http_options_2 = {"birdname": "Parrot"}
74-
server = TSC.Server("http://fake-url")
75-
with self.assertRaises(ValueError):
76-
server.add_http_options([http_options_1, http_options_2])
77-
78-
# TypeError: cannot convert dictionary update sequence element #0 to a sequence
79-
def test_http_options_not_sequence_fails(self):
80-
server = TSC.Server("http://fake-url")
81-
with self.assertRaises(ValueError):
82-
server.add_http_options({1, 2, 3})
83-
84-
def test_validate_connection_http(self):
85-
url = "http://cookies.com"
86-
server = TSC.Server(url)
87-
server.validate_connection_settings()
88-
self.assertEqual(url, server.server_address)
89-
90-
def test_validate_connection_https(self):
91-
url = "https://cookies.com"
92-
server = TSC.Server(url)
93-
server.validate_connection_settings()
94-
self.assertEqual(url, server.server_address)
95-
96-
def test_validate_connection_no_protocol(self):
97-
url = "cookies.com"
98-
fixed_url = "http://cookies.com"
99-
server = TSC.Server(url)
100-
server.validate_connection_settings()
101-
self.assertEqual(fixed_url, server.server_address)
102-
103-
104-
class SessionTests(unittest.TestCase):
105-
test_header = {"x-test": "true"}
106-
107-
@staticmethod
108-
def session_factory():
109-
session = requests.session()
110-
session.headers.update(SessionTests.test_header)
111-
return session
112-
113-
def test_session_factory_adds_headers(self):
114-
test_request_bin = "http://capture-this-with-mock.com"
115-
with requests_mock.mock() as m:
116-
m.get(url="http://capture-this-with-mock.com/api/2.4/serverInfo", request_headers=SessionTests.test_header)
117-
server = TSC.Server(test_request_bin, use_server_version=True, session_factory=SessionTests.session_factory)
31+
def test_init_server_model_empty_throws():
32+
with pytest.raises(TypeError):
33+
server = TSC.Server()
34+
35+
36+
def test_init_server_model_no_protocol_defaults_htt():
37+
server = TSC.Server("fake-url")
38+
39+
40+
def test_init_server_model_valid_server_name_works():
41+
server = TSC.Server("http://fake-url")
42+
43+
44+
def test_init_server_model_valid_https_server_name_works():
45+
# by default, it will just set the version to 2.3
46+
server = TSC.Server("https://fake-url")
47+
48+
49+
def test_init_server_model_bad_server_name_not_version_check():
50+
server = TSC.Server("fake-url", use_server_version=False)
51+
52+
53+
@mock.patch("requests.sessions.Session.get", side_effect=mocked_requests_get)
54+
def test_init_server_model_bad_server_name_do_version_check(mock_get):
55+
server = TSC.Server("fake-url", use_server_version=True)
56+
57+
58+
def test_init_server_model_bad_server_name_not_version_check_random_options():
59+
server = TSC.Server("fake-url", use_server_version=False, http_options={"foo": 1})
60+
61+
62+
def test_init_server_model_bad_server_name_not_version_check_real_options():
63+
server = TSC.Server("fake-url", use_server_version=False, http_options={"verify": False})
64+
65+
66+
def test_http_options_skip_ssl_works():
67+
http_options = {"verify": False}
68+
server = TSC.Server("http://fake-url")
69+
server.add_http_options(http_options)
70+
71+
72+
def test_http_options_multiple_options_works():
73+
http_options = {"verify": False, "birdname": "Parrot"}
74+
server = TSC.Server("http://fake-url")
75+
server.add_http_options(http_options)
76+
77+
78+
# ValueError: dictionary update sequence element #0 has length 1; 2 is required
79+
def test_http_options_multiple_dicts_fails():
80+
http_options_1 = {"verify": False}
81+
http_options_2 = {"birdname": "Parrot"}
82+
server = TSC.Server("http://fake-url")
83+
with pytest.raises(ValueError):
84+
server.add_http_options([http_options_1, http_options_2])
85+
86+
87+
# TypeError: cannot convert dictionary update sequence element #0 to a sequence
88+
def test_http_options_not_sequence_fails():
89+
server = TSC.Server("http://fake-url")
90+
with pytest.raises(ValueError):
91+
server.add_http_options({1, 2, 3})
92+
93+
94+
def test_validate_connection_http():
95+
url = "http://cookies.com"
96+
server = TSC.Server(url)
97+
server.validate_connection_settings()
98+
assert url == server.server_address
99+
100+
101+
def test_validate_connection_https():
102+
url = "https://cookies.com"
103+
server = TSC.Server(url)
104+
server.validate_connection_settings()
105+
assert url == server.server_address
106+
107+
108+
def test_validate_connection_no_protocol():
109+
url = "cookies.com"
110+
fixed_url = "http://cookies.com"
111+
server = TSC.Server(url)
112+
server.validate_connection_settings()
113+
assert fixed_url == server.server_address
114+
115+
116+
test_header = {"x-test": "true"}
117+
118+
119+
@pytest.fixture
120+
def session_factory() -> requests.Session:
121+
session = requests.session()
122+
session.headers.update(test_header)
123+
return session
124+
125+
126+
def test_session_factory_adds_headers(session_factory):
127+
test_request_bin = "http://capture-this-with-mock.com"
128+
with requests_mock.mock() as m:
129+
m.get(url="http://capture-this-with-mock.com/api/2.4/serverInfo", request_headers=test_header)
130+
server = TSC.Server(test_request_bin, use_server_version=True, session_factory=lambda: session_factory)
Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
1-
import unittest
21
import tableauserverclient as TSC
32
import tableauserverclient.server.request_factory as TSC_RF
43
from tableauserverclient import DatasourceItem
54

65

7-
class DatasourceRequestTests(unittest.TestCase):
8-
def test_generate_xml(self):
9-
datasource_item: TSC.DatasourceItem = TSC.DatasourceItem("name")
10-
datasource_item.name = "a ds"
11-
datasource_item.description = "described"
12-
datasource_item.use_remote_query_agent = False
13-
datasource_item.ask_data_enablement = DatasourceItem.AskDataEnablement.Enabled
14-
datasource_item.project_id = "testval"
15-
TSC_RF.RequestFactory.Datasource._generate_xml(datasource_item)
6+
def test_generate_xml():
7+
datasource_item: TSC.DatasourceItem = TSC.DatasourceItem("name")
8+
datasource_item.name = "a ds"
9+
datasource_item.description = "described"
10+
datasource_item.use_remote_query_agent = False
11+
datasource_item.ask_data_enablement = DatasourceItem.AskDataEnablement.Enabled
12+
datasource_item.project_id = "testval"
13+
TSC_RF.RequestFactory.Datasource._generate_xml(datasource_item)

0 commit comments

Comments
 (0)