diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 22a0854ee..f8549992f 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -22,6 +22,9 @@ IntervalItem, JobItem, JWTAuth, + LinkedTaskItem, + LinkedTaskStepItem, + LinkedTaskFlowRunItem, MetricItem, MonthlyInterval, PaginationItem, @@ -118,4 +121,7 @@ "Pager", "Server", "Sort", + "LinkedTaskItem", + "LinkedTaskStepItem", + "LinkedTaskFlowRunItem", ] diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index de0c516b7..41676da2c 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -23,6 +23,11 @@ HourlyInterval, ) from tableauserverclient.models.job_item import JobItem, BackgroundJobItem +from tableauserverclient.models.linked_tasks_item import ( + LinkedTaskItem, + LinkedTaskStepItem, + LinkedTaskFlowRunItem, +) from tableauserverclient.models.metric_item import MetricItem from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.models.permissions_item import PermissionsRule, Permission @@ -93,4 +98,7 @@ "ViewItem", "WebhookItem", "WorkbookItem", + "LinkedTaskItem", + "LinkedTaskStepItem", + "LinkedTaskFlowRunItem", ] diff --git a/tableauserverclient/models/linked_tasks_item.py b/tableauserverclient/models/linked_tasks_item.py new file mode 100644 index 000000000..ae9b60425 --- /dev/null +++ b/tableauserverclient/models/linked_tasks_item.py @@ -0,0 +1,102 @@ +import datetime as dt +from typing import List, Optional + +from defusedxml.ElementTree import fromstring + +from tableauserverclient.datetime_helpers import parse_datetime +from tableauserverclient.models.schedule_item import ScheduleItem + + +class LinkedTaskItem: + def __init__(self) -> None: + self.id: Optional[str] = None + self.num_steps: Optional[int] = None + self.schedule: Optional[ScheduleItem] = None + + @classmethod + def from_response(cls, resp: bytes, namespace) -> List["LinkedTaskItem"]: + parsed_response = fromstring(resp) + return [ + cls._parse_element(x, namespace) + for x in parsed_response.findall(".//t:linkedTasks[@id]", namespaces=namespace) + ] + + @classmethod + def _parse_element(cls, xml, namespace) -> "LinkedTaskItem": + task = cls() + task.id = xml.get("id") + task.num_steps = int(xml.get("numSteps")) + task.schedule = ScheduleItem.from_element(xml, namespace)[0] + return task + + +class LinkedTaskStepItem: + def __init__(self) -> None: + self.id: Optional[str] = None + self.step_number: Optional[int] = None + self.stop_downstream_on_failure: Optional[bool] = None + self.task_details: List[LinkedTaskFlowRunItem] = [] + + @classmethod + def from_task_xml(cls, xml, namespace) -> List["LinkedTaskStepItem"]: + return [cls._parse_element(x, namespace) for x in xml.findall(".//t:linkedTaskSteps[@id]", namespace)] + + @classmethod + def _parse_element(cls, xml, namespace) -> "LinkedTaskStepItem": + step = cls() + step.id = xml.get("id") + step.step_number = int(xml.get("stepNumber")) + step.stop_downstream_on_failure = string_to_bool(xml.get("stopDownstreamTasksOnFailure")) + step.task_details = LinkedTaskFlowRunItem._parse_element(xml, namespace) + return step + + +class LinkedTaskFlowRunItem: + def __init__(self) -> None: + self.flow_run_id: Optional[str] = None + self.flow_run_priority: Optional[int] = None + self.flow_run_consecutive_failed_count: Optional[int] = None + self.flow_run_task_type: Optional[str] = None + self.flow_id: Optional[str] = None + self.flow_name: Optional[str] = None + + @classmethod + def _parse_element(cls, xml, namespace) -> List["LinkedTaskFlowRunItem"]: + all_tasks = [] + for flow_run in xml.findall(".//t:flowRun[@id]", namespace): + task = cls() + task.flow_run_id = flow_run.get("id") + task.flow_run_priority = int(flow_run.get("priority")) + task.flow_run_consecutive_failed_count = int(flow_run.get("consecutiveFailedCount")) + task.flow_run_task_type = flow_run.get("type") + flow = flow_run.find(".//t:flow[@id]", namespace) + task.flow_id = flow.get("id") + task.flow_name = flow.get("name") + all_tasks.append(task) + + return all_tasks + + +class LinkedTaskJobItem: + def __init__(self) -> None: + self.id: Optional[str] = None + self.linked_task_id: Optional[str] = None + self.status: Optional[str] = None + self.created_at: Optional[dt.datetime] = None + + @classmethod + def from_response(cls, resp: bytes, namespace) -> "LinkedTaskJobItem": + parsed_response = fromstring(resp) + job = cls() + job_xml = parsed_response.find(".//t:linkedTaskJob[@id]", namespaces=namespace) + if job_xml is None: + raise ValueError("No linked task job found in response") + job.id = job_xml.get("id") + job.linked_task_id = job_xml.get("linkedTaskId") + job.status = job_xml.get("status") + job.created_at = parse_datetime(job_xml.get("createdAt")) + return job + + +def string_to_bool(s: str) -> bool: + return s.lower() == "true" diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index e6b50b27d..4c77fd320 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -14,6 +14,7 @@ from tableauserverclient.server.endpoint.groups_endpoint import Groups from tableauserverclient.server.endpoint.groupsets_endpoint import GroupSets from tableauserverclient.server.endpoint.jobs_endpoint import Jobs +from tableauserverclient.server.endpoint.linked_tasks_endpoint import LinkedTasks from tableauserverclient.server.endpoint.metadata_endpoint import Metadata from tableauserverclient.server.endpoint.metrics_endpoint import Metrics from tableauserverclient.server.endpoint.projects_endpoint import Projects @@ -46,6 +47,7 @@ "Groups", "GroupSets", "Jobs", + "LinkedTasks", "Metadata", "Metrics", "Projects", diff --git a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py new file mode 100644 index 000000000..374130509 --- /dev/null +++ b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py @@ -0,0 +1,45 @@ +from typing import List, Optional, Tuple, Union + +from tableauserverclient.helpers.logging import logger +from tableauserverclient.models.linked_tasks_item import LinkedTaskItem, LinkedTaskJobItem +from tableauserverclient.models.pagination_item import PaginationItem +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api +from tableauserverclient.server.request_factory import RequestFactory +from tableauserverclient.server.request_options import RequestOptions + + +class LinkedTasks(QuerysetEndpoint[LinkedTaskItem]): + def __init__(self, parent_srv): + super().__init__(parent_srv) + self._parent_srv = parent_srv + + @property + def baseurl(self) -> str: + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks/linked" + + @api(version="3.15") + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[LinkedTaskItem], PaginationItem]: + logger.info("Querying all linked tasks on site") + url = self.baseurl + server_response = self.get_request(url, req_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + all_group_items = LinkedTaskItem.from_response(server_response.content, self.parent_srv.namespace) + return all_group_items, pagination_item + + @api(version="3.15") + def get_by_id(self, linked_task: Union[LinkedTaskItem, str]) -> LinkedTaskItem: + task_id = getattr(linked_task, "id", linked_task) + logger.info("Querying all linked tasks on site") + url = f"{self.baseurl}/{task_id}" + server_response = self.get_request(url) + all_group_items = LinkedTaskItem.from_response(server_response.content, self.parent_srv.namespace) + return all_group_items[0] + + @api(version="3.15") + def run_now(self, linked_task: Union[LinkedTaskItem, str]) -> LinkedTaskJobItem: + task_id = getattr(linked_task, "id", linked_task) + logger.info(f"Running linked task {task_id} now") + url = f"{self.baseurl}/{task_id}/runNow" + empty_req = RequestFactory.Empty.empty_req() + server_response = self.post_request(url, empty_req) + return LinkedTaskJobItem.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 18d67fa07..1c457841b 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -33,6 +33,7 @@ Metrics, Endpoint, CustomViews, + LinkedTasks, GroupSets, ) from tableauserverclient.server.exceptions import ( @@ -100,6 +101,7 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self.flow_runs = FlowRuns(self) self.metrics = Metrics(self) self.custom_views = CustomViews(self) + self.linked_tasks = LinkedTasks(self) self.group_sets = GroupSets(self) self._session = self._session_factory() diff --git a/test/assets/linked_tasks_get.xml b/test/assets/linked_tasks_get.xml new file mode 100644 index 000000000..23b7bbbbc --- /dev/null +++ b/test/assets/linked_tasks_get.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + diff --git a/test/assets/linked_tasks_run_now.xml b/test/assets/linked_tasks_run_now.xml new file mode 100644 index 000000000..63cef73b1 --- /dev/null +++ b/test/assets/linked_tasks_run_now.xml @@ -0,0 +1,7 @@ + + + + diff --git a/test/test_linked_tasks.py b/test/test_linked_tasks.py new file mode 100644 index 000000000..8ea5226d7 --- /dev/null +++ b/test/test_linked_tasks.py @@ -0,0 +1,129 @@ +from pathlib import Path +import unittest + +from defusedxml.ElementTree import fromstring +import pytest +import requests_mock + +import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import parse_datetime +from tableauserverclient.models.linked_tasks_item import LinkedTaskItem, LinkedTaskStepItem, LinkedTaskFlowRunItem + +asset_dir = (Path(__file__).parent / "assets").resolve() + +GET_LINKED_TASKS = asset_dir / "linked_tasks_get.xml" +RUN_LINKED_TASK_NOW = asset_dir / "linked_tasks_run_now.xml" + + +class TestLinkedTasks(unittest.TestCase): + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) + self.server.version = "3.15" + + # Fake signin + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + + self.baseurl = self.server.linked_tasks.baseurl + + def test_parse_linked_task_flow_run(self): + xml = fromstring(GET_LINKED_TASKS.read_bytes()) + task_runs = LinkedTaskFlowRunItem._parse_element(xml, self.server.namespace) + assert 1 == len(task_runs) + task = task_runs[0] + assert task.flow_run_id == "e3d1fc25-5644-4e32-af35-58dcbd1dbd73" + assert task.flow_run_priority == 1 + assert task.flow_run_consecutive_failed_count == 3 + assert task.flow_run_task_type == "runFlow" + assert task.flow_id == "ab1231eb-b8ca-461e-a131-83f3c2b6a673" + assert task.flow_name == "flow-name" + + def test_parse_linked_task_step(self): + xml = fromstring(GET_LINKED_TASKS.read_bytes()) + steps = LinkedTaskStepItem.from_task_xml(xml, self.server.namespace) + assert 1 == len(steps) + step = steps[0] + assert step.id == "f554a4df-bb6f-4294-94ee-9a709ef9bda0" + assert step.stop_downstream_on_failure + assert step.step_number == 1 + assert 1 == len(step.task_details) + task = step.task_details[0] + assert task.flow_run_id == "e3d1fc25-5644-4e32-af35-58dcbd1dbd73" + assert task.flow_run_priority == 1 + assert task.flow_run_consecutive_failed_count == 3 + assert task.flow_run_task_type == "runFlow" + assert task.flow_id == "ab1231eb-b8ca-461e-a131-83f3c2b6a673" + assert task.flow_name == "flow-name" + + def test_parse_linked_task(self): + tasks = LinkedTaskItem.from_response(GET_LINKED_TASKS.read_bytes(), self.server.namespace) + assert 1 == len(tasks) + task = tasks[0] + assert task.id == "1b8211dc-51a8-45ce-a831-b5921708e03e" + assert task.num_steps == 1 + assert task.schedule is not None + assert task.schedule.id == "be077332-d01d-481b-b2f3-917e463d4dca" + + def test_get_linked_tasks(self): + with requests_mock.mock() as m: + m.get(self.baseurl, text=GET_LINKED_TASKS.read_text()) + tasks, pagination_item = self.server.linked_tasks.get() + + assert 1 == len(tasks) + task = tasks[0] + assert task.id == "1b8211dc-51a8-45ce-a831-b5921708e03e" + assert task.num_steps == 1 + assert task.schedule is not None + assert task.schedule.id == "be077332-d01d-481b-b2f3-917e463d4dca" + + def test_get_by_id_str_linked_task(self): + id_ = "1b8211dc-51a8-45ce-a831-b5921708e03e" + + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{id_}", text=GET_LINKED_TASKS.read_text()) + task = self.server.linked_tasks.get_by_id(id_) + + assert task.id == "1b8211dc-51a8-45ce-a831-b5921708e03e" + assert task.num_steps == 1 + assert task.schedule is not None + assert task.schedule.id == "be077332-d01d-481b-b2f3-917e463d4dca" + + def test_get_by_id_obj_linked_task(self): + id_ = "1b8211dc-51a8-45ce-a831-b5921708e03e" + in_task = LinkedTaskItem() + in_task.id = id_ + + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{id_}", text=GET_LINKED_TASKS.read_text()) + task = self.server.linked_tasks.get_by_id(in_task) + + assert task.id == "1b8211dc-51a8-45ce-a831-b5921708e03e" + assert task.num_steps == 1 + assert task.schedule is not None + assert task.schedule.id == "be077332-d01d-481b-b2f3-917e463d4dca" + + def test_run_now_str_linked_task(self): + id_ = "1b8211dc-51a8-45ce-a831-b5921708e03e" + + with requests_mock.mock() as m: + m.post(f"{self.baseurl}/{id_}/runNow", text=RUN_LINKED_TASK_NOW.read_text()) + job = self.server.linked_tasks.run_now(id_) + + assert job.id == "269a1e5a-1220-4a13-ac01-704982693dd8" + assert job.status == "InProgress" + assert job.created_at == parse_datetime("2022-02-15T00:22:22Z") + assert job.linked_task_id == id_ + + def test_run_now_obj_linked_task(self): + id_ = "1b8211dc-51a8-45ce-a831-b5921708e03e" + in_task = LinkedTaskItem() + in_task.id = id_ + + with requests_mock.mock() as m: + m.post(f"{self.baseurl}/{id_}/runNow", text=RUN_LINKED_TASK_NOW.read_text()) + job = self.server.linked_tasks.run_now(in_task) + + assert job.id == "269a1e5a-1220-4a13-ac01-704982693dd8" + assert job.status == "InProgress" + assert job.created_at == parse_datetime("2022-02-15T00:22:22Z") + assert job.linked_task_id == id_