diff --git a/samples/list.py b/samples/list.py index d1a25f08d..090d7dfdf 100644 --- a/samples/list.py +++ b/samples/list.py @@ -21,7 +21,7 @@ def main(): parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') - parser.add_argument('resource_type', choices=['workbook', 'datasource', 'view']) + parser.add_argument('resource_type', choices=['workbook', 'datasource', 'project', 'view', 'job']) args = parser.parse_args() @@ -41,7 +41,9 @@ def main(): endpoint = { 'workbook': server.workbooks, 'datasource': server.datasources, - 'view': server.views + 'view': server.views, + 'job': server.jobs, + 'project': server.projects, }.get(args.resource_type) for resource in TSC.Pager(endpoint.get): diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 30ec47981..3f2970281 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -1,6 +1,6 @@ from .namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE from .models import ConnectionCredentials, ConnectionItem, DatasourceItem,\ - GroupItem, JobItem, PaginationItem, ProjectItem, ScheduleItem, \ + GroupItem, JobItem, BackgroundJobItem, PaginationItem, ProjectItem, ScheduleItem, \ SiteItem, TableauAuth, UserItem, ViewItem, WorkbookItem, UnpopulatedPropertyError, \ HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval, IntervalItem, TaskItem, \ SubscriptionItem diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 1ff6be869..710831e07 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -4,7 +4,7 @@ from .exceptions import UnpopulatedPropertyError from .group_item import GroupItem from .interval_item import IntervalItem, DailyInterval, WeeklyInterval, MonthlyInterval, HourlyInterval -from .job_item import JobItem +from .job_item import JobItem, BackgroundJobItem from .pagination_item import PaginationItem from .project_item import ProjectItem from .schedule_item import ScheduleItem diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index cc53765ed..f8b68d87f 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -1,4 +1,5 @@ import xml.etree.ElementTree as ET +from ..datetime_helpers import parse_datetime from .target import Target @@ -58,3 +59,87 @@ def _parse_element(cls, element, ns): completed_at = element.get('completedAt', None) finish_code = element.get('finishCode', -1) return cls(id_, type_, created_at, started_at, completed_at, finish_code) + + +class BackgroundJobItem(object): + class Status: + Pending = "Pending" + InProgress = "InProgress" + Success = "Success" + Failed = "Failed" + Cancelled = "Cancelled" + + def __init__(self, id_, created_at, priority, job_type, status, title=None, subtitle=None, started_at=None, + ended_at=None): + self._id = id_ + self._type = job_type + self._status = status + self._created_at = created_at + self._started_at = started_at + self._ended_at = ended_at + self._priority = priority + self._title = title + self._subtitle = subtitle + + @property + def id(self): + return self._id + + @property + def name(self): + """For API consistency - all other resource endpoints have a name attribute which is used to display what + they are. Alias title as name to allow consistent handling of resources in the list sample.""" + return self._title + + @property + def status(self): + return self._status + + @property + def type(self): + return self._type + + @property + def created_at(self): + return self._created_at + + @property + def started_at(self): + return self._started_at + + @property + def ended_at(self): + return self._ended_at + + @property + def title(self): + return self._title + + @property + def subtitle(self): + return self._subtitle + + @property + def priority(self): + return self._priority + + @classmethod + def from_response(cls, xml, ns): + parsed_response = ET.fromstring(xml) + all_tasks_xml = parsed_response.findall( + './/t:backgroundJob', namespaces=ns) + return [cls._parse_element(x, ns) for x in all_tasks_xml] + + @classmethod + def _parse_element(cls, element, ns): + id_ = element.get('id', None) + type_ = element.get('jobType', None) + status = element.get('status', None) + created_at = parse_datetime(element.get('createdAt', None)) + started_at = parse_datetime(element.get('startedAt', None)) + ended_at = parse_datetime(element.get('endedAt', None)) + priority = element.get('priority', None) + title = element.get('title', None) + subtitle = element.get('subtitle', None) + + return cls(id_, created_at, priority, type_, status, title, subtitle, started_at, ended_at) diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index 704fdb66a..8c5cb314c 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -2,7 +2,7 @@ from .request_options import CSVRequestOptions, ImageRequestOptions, PDFRequestOptions, RequestOptions from .filter import Filter from .sort import Sort -from .. import ConnectionItem, DatasourceItem, JobItem, \ +from .. import ConnectionItem, DatasourceItem, JobItem, BackgroundJobItem, \ GroupItem, PaginationItem, ProjectItem, ScheduleItem, SiteItem, TableauAuth,\ UserItem, ViewItem, WorkbookItem, TaskItem, SubscriptionItem from .endpoint import Auth, Datasources, Endpoint, Groups, Projects, Schedules, \ diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index a19c32acd..1efb32f89 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -29,9 +29,9 @@ def _make_common_headers(auth_token, content_type): @staticmethod def _safe_to_log(server_response): - '''Checks if the server_response content is not xml (eg binary image or zip) - and and replaces it with a constant - ''' + """Checks if the server_response content is not xml (eg binary image or zip) + and replaces it with a constant + """ ALLOWED_CONTENT_TYPES = ('application/xml', 'application/xml;charset=utf-8') if server_response.headers.get('Content-Type', None) not in ALLOWED_CONTENT_TYPES: return '[Truncated File Contents]' @@ -90,7 +90,7 @@ def post_request(self, url, xml_request, content_type='text/xml'): def api(version): - '''Annotate the minimum supported version for an endpoint. + """Annotate the minimum supported version for an endpoint. Checks the version on the server object and compares normalized versions. It will raise an exception if the server version is > the version specified. @@ -106,23 +106,18 @@ def api(version): >>> @api(version="2.3") >>> def get(self, req_options=None): >>> ... - ''' + """ def _decorator(func): @wraps(func) def wrapper(self, *args, **kwargs): - server_version = Version(self.parent_srv.version or "0.0") - minimum_supported = Version(version) - if server_version < minimum_supported: - error = "This endpoint is not available in API version {}. Requires {}".format( - server_version, minimum_supported) - raise EndpointUnavailableError(error) + self.parent_srv.assert_at_least_version(version) return func(self, *args, **kwargs) return wrapper return _decorator def parameter_added_in(**params): - '''Annotate minimum versions for new parameters or request options on an endpoint. + """Annotate minimum versions for new parameters or request options on an endpoint. The api decorator documents when an endpoint was added, this decorator annotates keyword arguments on endpoints that may control functionality added after an endpoint was introduced. @@ -142,7 +137,7 @@ def parameter_added_in(**params): >>> @parameter_added_in(no_extract='2.5') >>> def download(self, workbook_id, filepath=None, extract_only=False): >>> ... - ''' + """ def _decorator(func): @wraps(func) def wrapper(self, *args, **kwargs): diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index 243b04d63..007f550ae 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -1,5 +1,5 @@ from .endpoint import Endpoint, api -from .. import JobItem +from .. import JobItem, BackgroundJobItem, PaginationItem import logging logger = logging.getLogger('tableau.endpoint.jobs') @@ -11,7 +11,20 @@ def baseurl(self): return "{0}/sites/{1}/jobs".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version='2.6') - def get(self, job_id): + def get(self, job_id=None, req_options=None): + # Backwards Compatibility fix until we rev the major version + if job_id is not None and isinstance(job_id, basestring): + import warnings + warnings.warn("Jobs.get(job_id) is deprecated, update code to use Jobs.get_by_id(job_id)") + return self.get_by_id(job_id) + self.parent_srv.assert_at_least_version('3.1') + server_response = self.get_request(self.baseurl, req_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + jobs = BackgroundJobItem.from_response(server_response.content, self.parent_srv.namespace) + return jobs, pagination_item + + @api(version='2.6') + def get_by_id(self, job_id): logger.info('Query for information about job ' + job_id) url = "{0}/{1}".format(self.baseurl, job_id) server_response = self.get_request(url) diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 0c2b4f1c2..95ee564ee 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -4,9 +4,15 @@ from ..namespace import Namespace from .endpoint import Sites, Views, Users, Groups, Workbooks, Datasources, Projects, Auth, \ Schedules, ServerInfo, Tasks, ServerInfoEndpointNotFoundError, Subscriptions, Jobs +from .endpoint.exceptions import EndpointUnavailableError import requests +try: + from distutils2.version import NormalizedVersion as Version +except ImportError: + from distutils.version import LooseVersion as Version + _PRODUCT_TO_REST_VERSION = { '10.0': '2.3', '9.3': '2.2', @@ -94,6 +100,14 @@ def use_highest_version(self): import warnings warnings.warn("use use_server_version instead", DeprecationWarning) + def assert_at_least_version(self, version): + server_version = Version(self.version or "0.0") + minimum_supported = Version(version) + if server_version < minimum_supported: + error = "This endpoint is not available in API version {}. Requires {}".format( + server_version, minimum_supported) + raise EndpointUnavailableError(error) + @property def baseurl(self): return "{0}/api/{1}".format(self._server_address, str(self.version)) diff --git a/test/assets/job_get.xml b/test/assets/job_get.xml new file mode 100644 index 000000000..4a9f271cc --- /dev/null +++ b/test/assets/job_get.xml @@ -0,0 +1,10 @@ + + + + + + + \ No newline at end of file diff --git a/test/test_job.py b/test/test_job.py new file mode 100644 index 000000000..674e54c67 --- /dev/null +++ b/test/test_job.py @@ -0,0 +1,45 @@ +import unittest +import os +from datetime import datetime +import requests_mock +import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import utc + +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') + +GET_XML = os.path.join(TEST_ASSET_DIR, 'job_get.xml') + + +class JobTests(unittest.TestCase): + def setUp(self): + self.server = TSC.Server('http://test') + self.server.version = '3.1' + + # Fake signin + self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' + self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + + self.baseurl = self.server.jobs.baseurl + + def test_get(self): + with open(GET_XML, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.get(self.baseurl, text=response_xml) + all_jobs, pagination_item = self.server.jobs.get() + job = all_jobs[0] + created_at = datetime(2018, 5, 22, 13, 0, 29, tzinfo=utc) + started_at = datetime(2018, 5, 22, 13, 0, 37, tzinfo=utc) + ended_at = datetime(2018, 5, 22, 13, 0, 45, tzinfo=utc) + self.assertEquals(1, pagination_item.total_available) + self.assertEquals('2eef4225-aa0c-41c4-8662-a76d89ed7336', job.id) + self.assertEquals('Success', job.status) + self.assertEquals('50', job.priority) + self.assertEquals('single_subscription_notify', job.type) + self.assertEquals(created_at, job.created_at) + self.assertEquals(started_at, job.started_at) + self.assertEquals(ended_at, job.ended_at) + + def test_get_before_signin(self): + self.server._auth_token = None + self.assertRaises(TSC.NotSignedInError, self.server.jobs.get)